#!/usr/bin/env python3 """Kvitt contract check (SPEC §11.2 M1 gate). Standard library only, Python 3.9+. Checks, one output line each: * every JSON file under contracts/ parses; * every test-vector file matches the format of SPEC §14.5.5 and covers the minimum cases; * a reference implementation of SPEC §9.1, §9.3, §9.4 and §14.5.2/§14.5.4 reproduces every expected value; * every example under contracts/examples/ matches the wire shapes of SPEC §14.3, §14.4 and §14.6 by key set and type, plus the money invariants of §9.1 / §9.4 / §14.3.3 (sums, sort orders); * contracts/schema/server-v1.sql is byte-identical to the DDL in SPEC §14.1, loads in SQLite, has a clean foreign_key_check and the seed rows; * contracts/schema/migrations/0002_categories_v2.sql loads on top of server-v1.sql (foreign keys on, one transaction, as sqlx runs it), on an empty database and on one with rows referring to categories, and leaves the 14 default categories with server_versions from the global sequence and schema_version 2; * contracts/category-keywords.csv (the keyword table of the category suggestion) is well-formed: header, match kinds, default keys, keywords already normalised, unique, sorted; * contracts/openapi.yaml declares exactly the endpoints of SPEC §7 and the error codes of §14.3.2, and the DefaultCategoryKey enum holds the 14 default keys (text check: the standard library has no YAML parser). Exit status 0 when every check passes, 1 otherwise. Usage: python3 tools/contracts-check.py [repository root] """ import csv import datetime import io import json import os import re import sqlite3 import sys import unicodedata ROOT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CONTRACTS = os.path.join(ROOT, "contracts") FAILURES = [] PASSES = [0] def report(ok, name, detail=""): if ok: PASSES[0] += 1 print("PASS %s%s" % (name, (": " + detail) if detail else "")) else: FAILURES.append(name) print("FAIL %s: %s" % (name, detail)) def rel(path): return os.path.relpath(path, ROOT) # ---------------------------------------------------------------------------------------------------------------------- # Reference implementation of the pure domain functions (SPEC §9.1, §9.3, §9.4, §14.5.2, §14.5.4) # ---------------------------------------------------------------------------------------------------------------------- class DomainError(Exception): def __init__(self, token): Exception.__init__(self, token) self.token = token AMOUNT_MIN, AMOUNT_MAX = 1, 1000000000 BP_TOTAL = 10000 MAX_ALLOCATIONS = 50 def compute_split(amount_ore, payer_user_id, user_ids, spec): """§9.1 and §14.5.2. Errors in SplitError enum order.""" mode = spec["mode"] # 1 amount_out_of_range if not (AMOUNT_MIN <= amount_ore <= AMOUNT_MAX): raise DomainError("amount_out_of_range") # 2 users_invalid if len(user_ids) != 2 or len(set(user_ids)) != 2: raise DomainError("users_invalid") # 3 payer_unknown if payer_user_id not in user_ids: raise DomainError("payer_unknown") if mode == "full": bearer = spec["bearer_user_id"] # 4-7 do not apply; 8 bearer_unknown; 9 share_users_mismatch cannot happen if bearer not in user_ids: raise DomainError("bearer_unknown") bp = {u: (BP_TOTAL if u == bearer else 0) for u in user_ids} return _ratio_rule(amount_ore, payer_user_id, user_ids, bp) if mode == "ratio": bp = spec["bp"] # 4 bp_out_of_range, 5 bp_sum_not_10000 if any(not (0 <= v <= BP_TOTAL) for v in bp.values()): raise DomainError("bp_out_of_range") if sum(bp.values()) != BP_TOTAL: raise DomainError("bp_sum_not_10000") # 9 share_users_mismatch if set(bp.keys()) != set(user_ids): raise DomainError("share_users_mismatch") return _ratio_rule(amount_ore, payer_user_id, user_ids, bp) if mode == "exact": ore = spec["ore"] # 6 exact_negative, 7 exact_sum_mismatch if any(v < 0 for v in ore.values()): raise DomainError("exact_negative") if sum(ore.values()) != amount_ore: raise DomainError("exact_sum_mismatch") if set(ore.keys()) != set(user_ids): raise DomainError("share_users_mismatch") return {u: ore[u] for u in user_ids} raise ValueError("unknown split mode %r" % mode) def _ratio_rule(amount_ore, payer, user_ids, bp): non_payer = [u for u in user_ids if u != payer][0] share_n = (amount_ore * bp[non_payer]) // BP_TOTAL # floor; all operands non-negative return {non_payer: share_n, payer: amount_ore - share_n} def compute_balances(user_ids, account_ids, expenses, settlements): """§9.3 and §14.5.2. Inputs hold only non-deleted rows.""" per = {} def acc(a): if a not in per: per[a] = {u: 0 for u in user_ids} return per[a] for a in account_ids: acc(a) for e in expenses: m = acc(e["account_id"]) m[e["payer_user_id"]] = m.get(e["payer_user_id"], 0) + e["amount_ore"] for u, s in e["shares"].items(): m[u] = m.get(u, 0) - s for s in settlements: for a, amt in s["allocations"].items(): m = acc(a) m[s["from_user_id"]] = m.get(s["from_user_id"], 0) + amt m[s["to_user_id"]] = m.get(s["to_user_id"], 0) - amt glob = {u: 0 for u in user_ids} for m in per.values(): for u, v in m.items(): glob[u] = glob.get(u, 0) + v return {"per_account": per, "global": glob} def _account_order(a): return (a["created_at"], a["account_id"]) def propose_allocation(amount_ore, accounts): """§9.4 algorithm, §14.5.2 semantics. Errors in AllocationError enum order.""" if not (AMOUNT_MIN <= amount_ore <= AMOUNT_MAX): raise DomainError("amount_out_of_range") if not accounts: raise DomainError("no_accounts") owing = sorted([a for a in accounts if a["payer_net_ore"] < 0], key=_account_order) allocations = [] remaining = amount_ore for a in owing: if remaining <= 0: break x = min(remaining, -a["payer_net_ore"]) allocations.append({"account_id": a["account_id"], "amount_ore": x}) remaining -= x excess = 0 over = [] if owing: if remaining > 0: allocations[-1]["amount_ore"] += remaining excess = remaining over = [allocations[-1]["account_id"]] else: active = sorted([a for a in accounts if not a["archived"]], key=_account_order) target = active[0] if active else sorted(accounts, key=_account_order)[0] allocations = [{"account_id": target["account_id"], "amount_ore": amount_ore}] excess = amount_ore over = [target["account_id"]] return {"allocations": allocations, "excess_ore": excess, "over_settled_account_ids": over} def validate_settlement(amount_ore, from_user_id, to_user_id, user_ids, allocations): """§9.4 settlement validity. Errors in SettlementError enum order. Returns None when valid.""" if not (AMOUNT_MIN <= amount_ore <= AMOUNT_MAX): raise DomainError("amount_out_of_range") if from_user_id == to_user_id: raise DomainError("same_user") if from_user_id not in user_ids or to_user_id not in user_ids: raise DomainError("user_unknown") if not allocations: raise DomainError("no_allocations") if len(allocations) > MAX_ALLOCATIONS: raise DomainError("too_many_allocations") if any(a["amount_ore"] < 1 for a in allocations): raise DomainError("allocation_not_positive") ids = [a["account_id"] for a in allocations] if len(set(ids)) != len(ids): raise DomainError("duplicate_account") if sum(a["amount_ore"] for a in allocations) != amount_ore: raise DomainError("allocation_sum_mismatch") return None def over_settled_accounts(allocations, accounts): """§9.4 over-settle warning, §14.5.2: unknown account counts as net 0.""" net = {a["account_id"]: a["payer_net_ore"] for a in accounts} return [a["account_id"] for a in allocations if a["amount_ore"] > max(0, -net.get(a["account_id"], 0))] def _d(s): return datetime.date(int(s[0:4]), int(s[5:7]), int(s[8:10])) def _month_key(d): return "%04d-%02d" % (d.year, d.month) def _week_key(d): y, w, _ = d.isocalendar() return "%04d-W%02d" % (y, w) def _month_last(d): nxt = datetime.date(d.year + (d.month // 12), d.month % 12 + 1, 1) return nxt - datetime.timedelta(days=1) def spend_series(expenses, range_, granularity, measure, group_by): """§14.5.4 spendSeries.""" start, end = _d(range_["start"]), _d(range_["end_inclusive"]) buckets = [] if granularity == "month": cur = datetime.date(start.year, start.month, 1) while cur <= end: last = _month_last(cur) buckets.append((_month_key(cur), max(cur, start), min(last, end))) cur = last + datetime.timedelta(days=1) keyf = _month_key elif granularity == "week": cur = start - datetime.timedelta(days=start.weekday()) while cur <= end: last = cur + datetime.timedelta(days=6) buckets.append((_week_key(cur), max(cur, start), min(last, end))) cur = last + datetime.timedelta(days=1) keyf = _week_key else: raise ValueError("granularity %r" % granularity) totals = {k: {} for k, _, _ in buckets} for e in expenses: d = _d(e["date"]) if d < start or d > end: continue if measure == "household": v = e["amount_ore"] else: v = e["shares"].get(measure["share"], 0) g = e["category_id"] if group_by == "category" else e["account_id"] t = totals[keyf(d)] t[g] = t.get(g, 0) + v out = [] group_totals = {} for k, s, en in buckets: t = {g: v for g, v in totals[k].items() if v != 0} for g, v in t.items(): group_totals[g] = group_totals.get(g, 0) + v out.append({"key": k, "start": s.isoformat(), "end_inclusive": en.isoformat(), "totals": t, "total": sum(t.values())}) group_totals = {g: v for g, v in group_totals.items() if v != 0} return {"buckets": out, "group_totals": group_totals, "grand_total": sum(group_totals.values())} def resolve_range(preset, today, earliest_expense, custom_start, custom_end): """§14.5.4 resolveRange.""" t = _d(today) def months_back(n): m = t.year * 12 + (t.month - 1) - (n - 1) return datetime.date(m // 12, m % 12 + 1, 1) if preset == "this_month": s, e = datetime.date(t.year, t.month, 1), t elif preset in ("last_3_months", "last_6_months", "last_12_months"): s, e = months_back(int(preset.split("_")[1])), t elif preset == "this_year": s, e = datetime.date(t.year, 1, 1), t elif preset == "all_time": s, e = (_d(earliest_expense) if earliest_expense else t), t elif preset == "custom": s, e = _d(custom_start), _d(custom_end) if s > e: s, e = e, s else: raise ValueError("preset %r" % preset) return {"start": s.isoformat(), "end_inclusive": e.isoformat()} # Category suggestion (UX-3, category-suggestion.md CS-4 to CS-6; Kotlin only) LETTER_CATEGORIES = ("Lu", "Ll", "Lt", "Lm", "Lo") def normalize_description(s): """CS-4: NFC, lowercase without locale rules, delete ' ’ :, every other character that is not a letter, a decimal digit or & becomes a space, collapse and trim. Works per code point.""" s = unicodedata.normalize("NFC", s).lower() s = s.replace("'", "").replace("\u2019", "").replace(":", "") kept = [] for c in s: cat = unicodedata.category(c) kept.append(c if (cat in LETTER_CATEGORIES or cat == "Nd" or c == "&") else " ") return " ".join("".join(kept).split()) def _keyword_matches(entry, tokens): kw, kind = entry["keyword"], entry["match"] if kind == "token": return kw in tokens if kind == "prefix": return any(t.startswith(kw) for t in tokens) if kind == "phrase": kt = kw.split(" ") return any(tokens[i:i + len(kt)] == kt for i in range(len(tokens) - len(kt) + 1)) raise ValueError("match %r" % kind) def _history_pick(rows, active, trace, stage): """CS-5 steps 1 and 2: most often among active categories, ties by the latest (expense_date, created_at) of each tied category. A remaining tie (same count, same latest date and created_at) goes to the lower category id (string order), as SPEC §9.11 CS-5 states.""" stats = {} for r in rows: cid = r["category_id"] if cid not in active: trace["skipped_inactive"] = True continue recency = (r["expense_date"], r["created_at"]) n, latest = stats.get(cid, (0, None)) stats[cid] = (n + 1, recency if latest is None or recency > latest else latest) if not stats: return None ranked = sorted(stats.items(), key=lambda kv: kv[0]) ranked.sort(key=lambda kv: kv[1][1], reverse=True) ranked.sort(key=lambda kv: kv[1][0], reverse=True) if len(ranked) > 1 and ranked[0][1][0] == ranked[1][1][0]: trace["count_tie"] = stage return ranked[0][0] def suggest_category_traced(description, history, keywords, active_categories, fallback): trace = {} active = set(c["id"] for c in active_categories) by_key = dict((c["default_key"], c["id"]) for c in active_categories if c["default_key"] is not None) norm = normalize_description(description) if not norm: trace["stage"] = "empty" return fallback, trace tokens = norm.split(" ") normalised = [(normalize_description(r["description"]), r) for r in history] got = _history_pick([r for n, r in normalised if n == norm], active, trace, "history_exact") if got is not None: trace["stage"] = "history_exact" return got, trace got = _history_pick([r for n, r in normalised if n and n.split(" ")[0] == tokens[0]], active, trace, "history_first_token") if got is not None: trace["stage"] = "history_first_token" return got, trace matching = [(i, e) for i, e in enumerate(keywords) if _keyword_matches(e, tokens)] matching.sort(key=lambda ie: (-len(ie[1]["keyword"]), ie[0])) if len(matching) > 1 and len(matching[0][1]["keyword"]) == len(matching[1][1]["keyword"]): trace["equal_length_tie"] = True for n, (_, e) in enumerate(matching): cid = by_key.get(e["default_key"]) if cid is not None: trace["stage"] = "keyword" trace["match"] = e["match"] if n > 0: trace["keyword_fell_through"] = True return cid, trace if matching: trace["keyword_fell_through"] = True trace["stage"] = "fallback" return fallback, trace def suggest_category(description, history, keywords, active_categories, fallback): """CS-4 to CS-6 as the pure Kotlin function suggestCategory (UX-3). `history` holds the non-deleted expenses, `active_categories` the categories that are neither archived nor deleted, `fallback` the AC-8.6 default.""" return suggest_category_traced(description, history, keywords, active_categories, fallback)[0] # ---------------------------------------------------------------------------------------------------------------------- # Test vectors (§14.5.5) # ---------------------------------------------------------------------------------------------------------------------- SPLIT_ERRORS = ["amount_out_of_range", "users_invalid", "payer_unknown", "bp_out_of_range", "bp_sum_not_10000", "exact_negative", "exact_sum_mismatch", "bearer_unknown", "share_users_mismatch"] ALLOCATION_ERRORS = ["amount_out_of_range", "no_accounts"] SETTLEMENT_ERRORS = ["amount_out_of_range", "same_user", "user_unknown", "no_allocations", "too_many_allocations", "allocation_not_positive", "duplicate_account", "allocation_sum_mismatch"] PRESETS = ["this_month", "last_3_months", "last_6_months", "last_12_months", "this_year", "all_time", "custom"] VECTOR_FUNCTIONS = { # function: (input keys = Rust parameter names, or snake_case Kotlin names for the Kotlin-only functions, # allowed error tokens, reference) "compute_split": (["amount_ore", "payer_user_id", "user_ids", "spec"], SPLIT_ERRORS, lambda i: compute_split(i["amount_ore"], i["payer_user_id"], i["user_ids"], i["spec"])), "compute_balances": (["user_ids", "account_ids", "expenses", "settlements"], [], lambda i: compute_balances(i["user_ids"], i["account_ids"], i["expenses"], i["settlements"])), "propose_allocation": (["amount_ore", "accounts"], ALLOCATION_ERRORS, lambda i: propose_allocation(i["amount_ore"], i["accounts"])), "validate_settlement": (["amount_ore", "from_user_id", "to_user_id", "user_ids", "allocations"], SETTLEMENT_ERRORS, lambda i: validate_settlement(i["amount_ore"], i["from_user_id"], i["to_user_id"], i["user_ids"], i["allocations"])), "over_settled_accounts": (["allocations", "accounts"], [], lambda i: over_settled_accounts(i["allocations"], i["accounts"])), "spend_series": (["expenses", "range", "granularity", "measure", "group_by"], [], lambda i: spend_series(i["expenses"], i["range"], i["granularity"], i["measure"], i["group_by"])), "resolve_range": (["preset", "today", "earliest_expense", "custom_start", "custom_end"], [], lambda i: resolve_range(i["preset"], i["today"], i["earliest_expense"], i["custom_start"], i["custom_end"])), "suggest_category": (["description", "history", "keywords", "active_categories", "fallback"], [], lambda i: suggest_category(i["description"], i["history"], i["keywords"], i["active_categories"], i["fallback"])), } SUGGEST_MIN_CASES = 30 DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$") PERIOD_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$") WEEK_RE = re.compile(r"^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$") UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") def is_int(v): return isinstance(v, int) and not isinstance(v, bool) def valid_date(s): if not isinstance(s, str) or not DATE_RE.match(s): return False try: _d(s) return True except ValueError: return False def valid_ts(s): if not isinstance(s, str) or not TS_RE.match(s): return False try: datetime.datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S") return True except ValueError: return False def check_vector_input(fn, inp): """Type check of a case input against the §14.5.1 types. Returns an error string or None.""" def int_map(m): return isinstance(m, dict) and all(isinstance(k, str) and is_int(v) for k, v in m.items()) def str_list(x): return isinstance(x, list) and all(isinstance(v, str) for v in x) def position(a): return (isinstance(a, dict) and set(a) == {"account_id", "created_at", "archived", "payer_net_ore"} and isinstance(a["account_id"], str) and valid_ts(a["created_at"]) and isinstance(a["archived"], bool) and is_int(a["payer_net_ore"])) def allocation(a): return isinstance(a, dict) and set(a) == {"account_id", "amount_ore"} and isinstance(a["account_id"], str) and is_int(a["amount_ore"]) if fn == "compute_split": sp = inp["spec"] if not (is_int(inp["amount_ore"]) and isinstance(inp["payer_user_id"], str) and str_list(inp["user_ids"])): return "scalar types" if not isinstance(sp, dict) or sp.get("mode") not in ("ratio", "exact", "full"): return "spec.mode must be ratio, exact or full" want = {"ratio": {"mode", "bp"}, "exact": {"mode", "ore"}, "full": {"mode", "bearer_user_id"}}[sp["mode"]] if set(sp) != want: return "spec keys %s, expected %s" % (sorted(sp), sorted(want)) if sp["mode"] == "ratio" and not int_map(sp["bp"]): return "spec.bp must map user id to int" if sp["mode"] == "exact" and not int_map(sp["ore"]): return "spec.ore must map user id to int" if sp["mode"] == "full" and not isinstance(sp["bearer_user_id"], str): return "spec.bearer_user_id must be a string" elif fn == "compute_balances": if not (str_list(inp["user_ids"]) and str_list(inp["account_ids"])): return "id lists" for e in inp["expenses"]: if not (isinstance(e, dict) and set(e) == {"account_id", "amount_ore", "payer_user_id", "shares"} and is_int(e["amount_ore"]) and int_map(e["shares"])): return "expense %r" % (e,) for s in inp["settlements"]: if not (isinstance(s, dict) and set(s) == {"from_user_id", "to_user_id", "allocations"} and int_map(s["allocations"])): return "settlement %r" % (s,) elif fn == "propose_allocation": if not is_int(inp["amount_ore"]) or not all(position(a) for a in inp["accounts"]): return "amount or account positions" elif fn == "validate_settlement": if not (is_int(inp["amount_ore"]) and str_list(inp["user_ids"]) and all(allocation(a) for a in inp["allocations"])): return "amount, user ids or allocations" elif fn == "over_settled_accounts": if not (all(allocation(a) for a in inp["allocations"]) and all(position(a) for a in inp["accounts"])): return "allocations or account positions" elif fn == "spend_series": for e in inp["expenses"]: if not (isinstance(e, dict) and set(e) == {"date", "account_id", "category_id", "amount_ore", "shares"} and valid_date(e["date"]) and is_int(e["amount_ore"]) and int_map(e["shares"])): return "expense %r" % (e,) r = inp["range"] if not (isinstance(r, dict) and set(r) == {"start", "end_inclusive"} and valid_date(r["start"]) and valid_date(r["end_inclusive"])): return "range" if inp["granularity"] not in ("month", "week") or inp["group_by"] not in ("category", "account"): return "granularity or group_by" m = inp["measure"] if not (m == "household" or (isinstance(m, dict) and set(m) == {"share"} and isinstance(m["share"], str))): return "measure must be \"household\" or {\"share\": id}" elif fn == "resolve_range": if inp["preset"] not in PRESETS or not valid_date(inp["today"]): return "preset or today" for k in ("earliest_expense", "custom_start", "custom_end"): if inp[k] is not None and not valid_date(inp[k]): return k elif fn == "suggest_category": if not isinstance(inp["description"], str): return "description must be a string" for h in inp["history"]: if not (isinstance(h, dict) and set(h) == {"description", "category_id", "expense_date", "created_at"} and isinstance(h["description"], str) and h["description"] and UUID_RE.match(str(h["category_id"])) and valid_date(h["expense_date"]) and valid_ts(h["created_at"])): return "history row %r" % (h,) for k in inp["keywords"]: if not (isinstance(k, dict) and set(k) == {"keyword", "match", "default_key"} and isinstance(k["keyword"], str) and k["match"] in KEYWORD_MATCHES and k["default_key"] in DEFAULT_KEYS): return "keyword %r" % (k,) for c in inp["active_categories"]: if not (isinstance(c, dict) and set(c) == {"id", "default_key"} and UUID_RE.match(str(c["id"])) and (c["default_key"] is None or c["default_key"] in DEFAULT_KEYS)): return "active category %r" % (c,) ids = [c["id"] for c in inp["active_categories"]] keys = [c["default_key"] for c in inp["active_categories"] if c["default_key"] is not None] if len(set(ids)) != len(ids) or len(set(keys)) != len(keys): return "active_categories: duplicate id or default_key" if inp["fallback"] is not None and not UUID_RE.match(str(inp["fallback"])): return "fallback must be a category id or null" return None def check_vector_ok_shape(fn, ok): if fn == "compute_split": return isinstance(ok, dict) and all(is_int(v) for v in ok.values()) if fn == "compute_balances": return isinstance(ok, dict) and set(ok) == {"per_account", "global"} if fn == "propose_allocation": return (isinstance(ok, dict) and set(ok) == {"allocations", "excess_ore", "over_settled_account_ids"} and all(isinstance(a, dict) and set(a) == {"account_id", "amount_ore"} for a in ok["allocations"])) if fn == "validate_settlement": return ok is None if fn == "over_settled_accounts": return isinstance(ok, list) if fn == "spend_series": return (isinstance(ok, dict) and set(ok) == {"buckets", "group_totals", "grand_total"} and all(isinstance(b, dict) and set(b) == {"key", "start", "end_inclusive", "totals", "total"} for b in ok["buckets"])) if fn == "resolve_range": return isinstance(ok, dict) and set(ok) == {"start", "end_inclusive"} if fn == "suggest_category": return ok is None or (isinstance(ok, str) and bool(UUID_RE.match(ok))) return False def coverage(fn, cases): """Minimum cases of §14.5.5 that can be checked mechanically. Returns a list of missing items.""" missing = [] errors = set(c["expected"].get("error") for c in cases if "error" in c["expected"]) if fn == "compute_split": missing += ["error " + e for e in SPLIT_ERRORS if e not in errors] rows = [(1001, "a", 501, 500), (1, "a", 1, 0), (100, "b", 40, 60), (999, "a", 333, 666), (999, "b", 667, 332), (50000, "a", 0, 50000), (50000, "a", 50000, 0)] for amt, payer, sp, sn in rows: hit = any(c["input"]["amount_ore"] == amt and c["input"]["payer_user_id"] == payer and c["expected"].get("ok") is not None and c["expected"]["ok"].get(payer) == sp and sorted(c["expected"]["ok"].values()) == sorted([sp, sn]) for c in cases) if not hit: missing.append("§9.1 row %d/%s" % (amt, payer)) modes = set((c["input"]["spec"]["mode"], "ok" if "ok" in c["expected"] else "err") for c in cases) for m in [("full", "ok"), ("exact", "ok")]: if m not in modes: missing.append("%s %s" % m) if not any(c["input"]["spec"]["mode"] == "full" and c["input"]["spec"]["bearer_user_id"] == c["input"]["payer_user_id"] and "ok" in c["expected"] for c in cases): missing.append("full on payer") if not any(c["input"]["spec"]["mode"] == "full" and c["input"]["spec"]["bearer_user_id"] != c["input"]["payer_user_id"] and "ok" in c["expected"] for c in cases): missing.append("full on non-payer") amounts = set(c["input"]["amount_ore"] for c in cases) for a in (0, 1000000001, 1000000000): if a not in amounts: missing.append("amount %d" % a) elif fn == "propose_allocation": missing += ["error " + e for e in ALLOCATION_ERRORS if e not in errors] oks = [c["expected"]["ok"] for c in cases if "ok" in c["expected"]] if not any(o["excess_ore"] > 0 and len(o["allocations"]) > 1 for o in oks): missing.append("excess added to the last owing account") if not any(o["excess_ore"] == 0 and o["allocations"] for o in oks): missing.append("no excess") elif fn == "validate_settlement": missing += ["error " + e for e in SETTLEMENT_ERRORS if e not in errors] valid_sizes = set(len(c["input"]["allocations"]) for c in cases if "ok" in c["expected"]) for n in (3, 50): if n not in valid_sizes: missing.append("valid case with %d allocations" % n) elif fn == "over_settled_accounts": oks = [c["expected"]["ok"] for c in cases] if [] not in oks: missing.append("none") if not any(len(o) == 1 for o in oks): missing.append("one") elif fn == "spend_series": seen = set() for c in cases: i = c["input"] seen.add(i["granularity"]) seen.add("household" if i["measure"] == "household" else "share") seen.add(i["group_by"]) for b in c["expected"]["ok"]["buckets"]: seen.add(b["key"]) if b["total"] == 0: seen.add("empty bucket") for want in ("month", "week", "household", "share", "category", "account", "2026-W53", "empty bucket"): if want not in seen: missing.append(want) elif fn == "resolve_range": presets = set(c["input"]["preset"] for c in cases) missing += ["preset " + p for p in PRESETS if p not in presets] if not any(c["input"]["preset"] == "all_time" and c["input"]["earliest_expense"] is None for c in cases): missing.append("all_time without earliest expense") if not any(c["input"]["preset"] == "all_time" and c["input"]["earliest_expense"] is not None for c in cases): missing.append("all_time with earliest expense") if not any(c["input"]["preset"] == "custom" and c["input"]["custom_start"] > c["input"]["custom_end"] for c in cases): missing.append("custom swapped") if not all(any(c["input"]["preset"] == p and c["input"]["today"] == "2026-10-17" for c in cases) for p in PRESETS if p != "custom"): missing.append("every preset with today 2026-10-17") elif fn == "suggest_category": if len(cases) < SUGGEST_MIN_CASES: missing.append("at least %d cases (has %d)" % (SUGGEST_MIN_CASES, len(cases))) seen = set() for c in cases: i = c["input"] _, t = suggest_category_traced(i["description"], i["history"], i["keywords"], i["active_categories"], i["fallback"]) seen.add(t["stage"]) if i["description"] == "": seen.add("empty description") if t["stage"] == "keyword": seen.add("keyword " + t["match"]) for k in ("skipped_inactive", "equal_length_tie", "keyword_fell_through"): if t.get(k): seen.add(k) if t.get("count_tie"): seen.add("tie " + t["count_tie"]) if c["expected"]["ok"] is None: seen.add("null result") if normalize_description(i["description"]) != " ".join(i["description"].split()).lower(): seen.add("normalisation changes the description") for want in ("empty description", "empty", "history_exact", "history_first_token", "keyword", "fallback", "keyword token", "keyword prefix", "keyword phrase", "skipped_inactive", "equal_length_tie", "keyword_fell_through", "tie history_exact", "null result", "normalisation changes the description"): if want not in seen: missing.append(want) return missing def check_vectors(): d = os.path.join(CONTRACTS, "test-vectors") expected_files = set(f + ".json" for f in VECTOR_FUNCTIONS) present = set(f for f in os.listdir(d) if f.endswith(".json")) if os.path.isdir(d) else set() report(present == expected_files, "vectors: file set is exactly §14.5.5", "missing %s, extra %s" % (sorted(expected_files - present), sorted(present - expected_files)) if present != expected_files else "%d files" % len(present)) counts = {} for fn in sorted(VECTOR_FUNCTIONS): path = os.path.join(d, fn + ".json") if not os.path.exists(path): continue try: with open(path, encoding="utf-8") as f: doc = json.load(f) except ValueError as e: report(False, "vectors %s format" % fn, "does not parse: %s" % e) continue keys, errs, ref = VECTOR_FUNCTIONS[fn] problems = [] if not isinstance(doc, dict) or set(doc) != {"format", "function", "cases"}: problems.append("top-level keys must be format, function, cases") else: if doc["format"] != 1: problems.append("format must be 1") if doc["function"] != fn: problems.append("function %r does not match file name" % doc["function"]) if not isinstance(doc["cases"], list) or not doc["cases"]: problems.append("cases must be a non-empty list") cases = doc.get("cases") if isinstance(doc, dict) and isinstance(doc.get("cases"), list) else [] names = set() for n, c in enumerate(cases): where = "case %d" % n if not isinstance(c, dict) or set(c) != {"name", "input", "expected"}: problems.append("%s: keys must be name, input, expected" % where) continue if not isinstance(c["name"], str) or not c["name"]: problems.append("%s: name must be a non-empty string" % where) elif c["name"] in names: problems.append("%s: duplicate name %r" % (where, c["name"])) names.add(c["name"]) if not isinstance(c["input"], dict) or set(c["input"]) != set(keys): problems.append("%s (%s): input keys %s, expected %s" % (where, c["name"], sorted(c["input"]) if isinstance(c["input"], dict) else "?", sorted(keys))) continue bad = check_vector_input(fn, c["input"]) if bad: problems.append("%s (%s): input %s" % (where, c["name"], bad)) exp = c["expected"] if not isinstance(exp, dict) or len(exp) != 1 or list(exp)[0] not in ("ok", "error"): problems.append("%s (%s): expected must be {\"ok\": …} or {\"error\": …}" % (where, c["name"])) continue if "error" in exp and exp["error"] not in errs: problems.append("%s (%s): error token %r not allowed for %s" % (where, c["name"], exp["error"], fn)) if "ok" in exp and not check_vector_ok_shape(fn, exp["ok"]): problems.append("%s (%s): ok value has the wrong shape" % (where, c["name"])) report(not problems, "vectors %s.json format (§14.5.5)" % fn, "; ".join(problems) if problems else "%d cases" % len(cases)) if problems: continue missing = coverage(fn, cases) report(not missing, "vectors %s.json minimum cases (§14.5.5)" % fn, "missing: " + ", ".join(missing) if missing else "covered") mismatches = [] for c in cases: try: got = {"ok": ref(c["input"])} except DomainError as e: got = {"error": e.token} if got != c["expected"]: mismatches.append("%r: expected %s, reference gives %s" % (c["name"], json.dumps(c["expected"], ensure_ascii=False), json.dumps(got, ensure_ascii=False))) report(not mismatches, "vectors %s.json reproduced by the reference implementation" % fn, " | ".join(mismatches) if mismatches else "%d/%d cases" % (len(cases), len(cases))) counts[fn] = len(cases) return counts # ---------------------------------------------------------------------------------------------------------------------- # Wire shapes (§14.3.3, §14.3.4, §14.4.2, §14.4.3, §14.6) # ---------------------------------------------------------------------------------------------------------------------- # A type is: "uuid" | "string" | "int" | "bool" | "ts" | "date" | "period" | "any" | "hex32" # | ("enum", [values]) | ("null", type) | ("array", type, min, max) | "" # A shape is {"fields": {key: type}, "optional": set of keys that may be absent, "patch": bool (all optional, ≥ 1)} def N(t): return ("null", t) def A(t, lo=0, hi=None): return ("array", t, lo, hi) def E(*vals): return ("enum", list(vals)) ROLE = E("admin", "member") INVITE_KIND = E("admin_setup", "partner", "device") DEFAULT_KEYS = ["groceries", "restaurants", "home", "utilities", "transport", "travel", "entertainment", "health", "shopping", "other", "housing", "insurance", "children", "fees"] DEFAULT_KEY = E(*DEFAULT_KEYS) KEYWORD_MATCHES = ["token", "prefix", "phrase"] SPLIT_MODE = E("default", "ratio", "exact", "full") ENTITY_TYPE = E("expense", "settlement") REASON = E("stale", "deleted", "validation_failed", "reference_not_found", "clock_skew") ERROR_CODES = ["invalid_request", "invite_invalid", "unauthorized", "device_revoked", "forbidden", "not_found", "household_full", "household_incomplete", "name_taken", "account_not_empty", "category_in_use", "category_is_default", "cannot_revoke_current_device", "no_accounts", "cursor_ahead", "payload_too_large", "unsupported_media_type", "validation_failed", "image_invalid", "batch_too_large", "protocol_unsupported", "rate_limited", "internal", "not_implemented", "unavailable"] PUSH_TYPES = ["expense_created", "expense_updated", "expense_deleted", "settlement_created", "settlement_updated", "settlement_deleted", "recurring_expense_created", "sync_batch"] ICONS = ["shopping_cart", "restaurant", "local_cafe", "home", "bolt", "water_drop", "wifi", "directions_car", "train", "local_gas_station", "flight", "hotel", "luggage", "movie", "music_note", "sports_esports", "fitness_center", "medical_services", "medication", "shopping_bag", "checkroom", "pets", "child_care", "school", "card_giftcard", "celebration", "build", "chair", "subscriptions", "category", "key", "shield", "account_balance"] COLORS = ["green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime", "slate", "amber"] def S(fields, optional=(), patch=False): return {"fields": fields, "optional": set(optional), "patch": patch} TEMPLATE_WRITABLE = { "account_id": "uuid", "category_id": "uuid", "amount_ore": "int", "description": "string", "notes": "string", "payer_user_id": "uuid", "split_mode": SPLIT_MODE, "splits": A("TemplateSplit", 0, 2), "day_of_month": "int", "start_period": "period", "end_period": N("period"), "paused": "bool", } SHAPES = { # §14.3.3 wire objects "User": S({"id": "uuid", "role": ROLE, "display_name": "string", "picture_version": N("int"), "created_at": "ts", "updated_at": "ts", "server_version": "int"}), "Device": S({"id": "uuid", "user_id": "uuid", "name": "string", "app_version_code": N("int"), "app_version_name": N("string"), "created_at": "ts", "last_seen_at": "ts", "revoked_at": N("ts"), "is_current": "bool"}), "Invite": S({"id": "uuid", "kind": INVITE_KIND, "target_user_id": N("uuid"), "code": N("string"), "link": N("string"), "created_at": "ts", "expires_at": "ts"}), "Share": S({"user_id": "uuid", "share_bp": "int"}), "Account": S({"id": "uuid", "name": "string", "default_shares": A("Share", 2, 2), "created_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts", "archived_at": N("ts"), "deleted_at": N("ts"), "server_version": "int"}), "Category": S({"id": "uuid", "default_key": N(DEFAULT_KEY), "name": N("string"), "icon": "string", "color": "string", "sort_order": "int", "created_at": "ts", "updated_at": "ts", "archived_at": N("ts"), "deleted_at": N("ts"), "server_version": "int"}), "Split": S({"user_id": "uuid", "share_ore": "int", "share_bp": N("int")}), "TemplateSplit": S({"user_id": "uuid", "share_bp": N("int"), "share_ore": N("int")}), "Expense": S({"id": "uuid", "account_id": "uuid", "category_id": "uuid", "amount_ore": "int", "description": "string", "notes": "string", "expense_date": "date", "payer_user_id": "uuid", "split_mode": SPLIT_MODE, "splits": A("Split", 2, 2), "recurring_template_id": N("uuid"), "recurring_period": N("period"), "created_by_user_id": "uuid", "updated_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts", "deleted_at": N("ts"), "server_version": "int"}), "Allocation": S({"account_id": "uuid", "amount_ore": "int"}), "Settlement": S({"id": "uuid", "from_user_id": "uuid", "to_user_id": "uuid", "amount_ore": "int", "settlement_date": "date", "note": "string", "allocations": A("Allocation", 1, 50), "created_by_user_id": "uuid", "updated_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts", "deleted_at": N("ts"), "server_version": "int"}), "RecurringTemplate": S(dict(TEMPLATE_WRITABLE, **{ "id": "uuid", "last_generated_period": N("period"), "next_occurrence_date": N("date"), "created_by_user_id": "uuid", "updated_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts", "deleted_at": N("ts"), "server_version": "int"})), "AuditChange": S({"field": "string", "old": "any", "new": "any"}), "AuditEntry": S({"id": "uuid", "entity_type": ENTITY_TYPE, "entity_id": "uuid", "action": E("create", "update", "delete"), "source": E("app", "recurring", "import"), "actor_user_id": "uuid", "client_at": "ts", "server_at": "ts", "changes": A("AuditChange"), "server_version": "int"}), "Error": S({"code": E(*ERROR_CODES), "message": "string", "field": N("string")}), # §14.3.4 request and response bodies "Health": S({"status": E("ok", "degraded"), "server_version": "string", "schema_version": "int", "db_ok": "bool"}), "SetupStatus": S({"initialized": "bool"}), "AcceptInviteRequest": S({"code": "string", "device_name": "string", "display_name": "string"}, optional=["display_name"]), "AcceptInviteResponse": S({"token": "string", "invite_kind": INVITE_KIND, "user": "User", "device": "Device", "household": A("User", 1, 2)}), "ApkLatest": S({"version_code": "int", "version_name": "string", "file_name": "string", "size_bytes": "int", "sha256": "string", "released_at": "ts"}), "MeResponse": S({"user": "User", "device": "Device", "household": A("User", 1, 2), "public_url": "string"}), "PatchMeRequest": S({"display_name": "string"}, patch=True), "CreateInviteRequest": S({"kind": E("partner", "device"), "target_user_id": N("uuid")}, optional=["target_user_id"]), "InviteList": S({"invites": A("Invite")}), "DeviceList": S({"devices": A("Device")}), "PushTokenRequest": S({"fcm_token": N("string")}), "CreateAccountRequest": S({"name": "string", "default_shares": A("Share", 2, 2)}), "PatchAccountRequest": S({"name": "string", "default_shares": A("Share", 2, 2)}, patch=True), "CreateCategoryRequest": S({"name": "string", "icon": "string", "color": "string"}), "PatchCategoryRequest": S({"name": N("string"), "icon": "string", "color": "string", "sort_order": "int"}, patch=True), "CreateRecurringTemplateRequest": S(dict(TEMPLATE_WRITABLE)), "PatchRecurringTemplateRequest": S(dict(TEMPLATE_WRITABLE), patch=True), "ProposalRequest": S({"payer_user_id": "uuid", "amount_ore": "int", "exclude_settlement_id": N("uuid")}, optional=["exclude_settlement_id"]), "ProposalResponse": S({"allocations": A("Allocation"), "excess_ore": "int", "over_settled_account_ids": A("uuid")}), # §14.4.2 and §14.4.3 sync "SyncPullResponse": S({"epoch": "hex32", "since": "int", "next_since": "int", "has_more": "bool", "server_time": "ts", "users": A("User"), "categories": A("Category"), "accounts": A("Account"), "recurring_templates": A("RecurringTemplate"), "expenses": A("Expense"), "settlements": A("Settlement"), "audit_entries": A("AuditEntry")}), "PushedExpense": S({"id": "uuid", "account_id": "uuid", "category_id": "uuid", "amount_ore": "int", "description": "string", "notes": "string", "expense_date": "date", "payer_user_id": "uuid", "split_mode": SPLIT_MODE, "splits": A("Split", 2, 2), "created_at": "ts", "updated_at": "ts", "deleted_at": N("ts")}), "PushedSettlement": S({"id": "uuid", "from_user_id": "uuid", "to_user_id": "uuid", "amount_ore": "int", "settlement_date": "date", "note": "string", "allocations": A("Allocation", 1, 50), "created_at": "ts", "updated_at": "ts", "deleted_at": N("ts")}), "ExpenseMutation": S({"mutation_id": "uuid", "entity_type": E("expense"), "entity": "PushedExpense"}), "SettlementMutation": S({"mutation_id": "uuid", "entity_type": E("settlement"), "entity": "PushedSettlement"}), "SyncPushRequest": S({"mutations": A("Mutation", 1, 100)}), "AcceptedExpense": S({"mutation_id": "uuid", "entity_type": E("expense"), "entity_id": "uuid", "status": E("accepted"), "server_version": "int", "entity": "Expense"}), "AcceptedSettlement": S({"mutation_id": "uuid", "entity_type": E("settlement"), "entity_id": "uuid", "status": E("accepted"), "server_version": "int", "entity": "Settlement"}), "RejectedExpense": S({"mutation_id": "uuid", "entity_type": E("expense"), "entity_id": "uuid", "status": E("rejected"), "reason": REASON, "message": "string", "current": N("Expense")}), "RejectedSettlement": S({"mutation_id": "uuid", "entity_type": E("settlement"), "entity_id": "uuid", "status": E("rejected"), "reason": REASON, "message": "string", "current": N("Settlement")}), "SyncPushResponse": S({"server_time": "ts", "results": A("MutationResult", 1, 100)}), # §14.6 push payload "FcmMessage": S({"message": "FcmBody"}), "FcmBody": S({"fid": "string", "android": "FcmAndroid", "data": "FcmData"}), "FcmAndroid": S({"priority": E("high"), "ttl": E("86400s")}), "FcmData": S({"v": E("1"), "type": E(*PUSH_TYPES), "entity_type": E("expense", "settlement", ""), "entity_id": "string", "actor_user_id": "uuid", "count": "string"}), } UNIONS = { "Mutation": lambda o: {"expense": "ExpenseMutation", "settlement": "SettlementMutation"}.get(o.get("entity_type")), "MutationResult": lambda o: {("expense", "accepted"): "AcceptedExpense", ("settlement", "accepted"): "AcceptedSettlement", ("expense", "rejected"): "RejectedExpense", ("settlement", "rejected"): "RejectedSettlement" }.get((o.get("entity_type"), o.get("status"))), } def validate(value, typ, path, errs, seen): """Validate value against typ; append messages to errs; record (shape, object) pairs in seen.""" if isinstance(typ, tuple): kind = typ[0] if kind == "null": if value is not None: validate(value, typ[1], path, errs, seen) return if kind == "enum": if value not in typ[1]: errs.append("%s: %r is not one of %s" % (path, value, typ[1])) return if kind == "array": if not isinstance(value, list): errs.append("%s: expected an array" % path) return lo, hi = typ[2], typ[3] if len(value) < lo or (hi is not None and len(value) > hi): errs.append("%s: array length %d outside %s..%s" % (path, len(value), lo, hi)) for i, v in enumerate(value): validate(v, typ[1], "%s[%d]" % (path, i), errs, seen) return if typ in UNIONS: if not isinstance(value, dict): errs.append("%s: expected an object" % path) return member = UNIONS[typ](value) if member is None: errs.append("%s: no %s variant for this object" % (path, typ)) return validate(value, member, path, errs, seen) return if typ in SHAPES: shape = SHAPES[typ] if not isinstance(value, dict): errs.append("%s: expected a %s object" % (path, typ)) return keys, fields = set(value), set(shape["fields"]) if shape["patch"]: if not keys or not keys <= fields: errs.append("%s: %s keys %s must be a non-empty subset of %s" % (path, typ, sorted(keys), sorted(fields))) else: required = fields - shape["optional"] if not (required <= keys <= fields): errs.append("%s: %s keys differ: missing %s, unknown %s" % (path, typ, sorted(required - keys), sorted(keys - fields))) for k in keys & fields: validate(value[k], shape["fields"][k], "%s.%s" % (path, k), errs, seen) seen.append((typ, value, path)) return ok = { "uuid": lambda v: isinstance(v, str) and bool(UUID_RE.match(v)), "string": lambda v: isinstance(v, str), "int": is_int, "bool": lambda v: isinstance(v, bool), "ts": valid_ts, "date": valid_date, "period": lambda v: isinstance(v, str) and bool(PERIOD_RE.match(v)), "hex32": lambda v: isinstance(v, str) and bool(re.match(r"^[0-9a-f]{32}$", v)), "any": lambda v: True, }.get(typ) if ok is None: errs.append("%s: unknown type %r in the checker" % (path, typ)) elif not ok(value): errs.append("%s: %r is not a valid %s" % (path, value, typ)) def semantic(shape, o, path, errs): """Invariants of §9.1, §9.4, §9.6, §9.7, §14.3.3, §14.6 on objects that passed the shape check.""" def sorted_by(lst, key): ids = [x[key] for x in lst] return ids == sorted(ids) if shape in ("Expense", "PushedExpense"): sp = o["splits"] if not sorted_by(sp, "user_id"): errs.append("%s.splits: not sorted by user_id" % path) if sum(s["share_ore"] for s in sp) != o["amount_ore"] and shape == "Expense": errs.append("%s.splits: shares do not sum to amount_ore" % path) if shape == "Expense" and len(sp) == 2: users = [s["user_id"] for s in sp] try: if o["split_mode"] == "exact": if any(s["share_bp"] is not None for s in sp): errs.append("%s.splits: share_bp must be null for mode exact" % path) spec = {"mode": "exact", "ore": {s["user_id"]: s["share_ore"] for s in sp}} else: spec = {"mode": "ratio", "bp": {s["user_id"]: s["share_bp"] for s in sp}} if any(s["share_bp"] is None for s in sp): raise DomainError("share_bp null in mode %s" % o["split_mode"]) if o["split_mode"] == "full" and sorted(s["share_bp"] for s in sp) != [0, 10000]: errs.append("%s.splits: mode full needs bp 10000 and 0" % path) want = compute_split(o["amount_ore"], o["payer_user_id"], users, spec) got = {s["user_id"]: s["share_ore"] for s in sp} if want != got: errs.append("%s.splits: share_ore %s, §9.1 gives %s" % (path, got, want)) except DomainError as e: errs.append("%s.splits: %s" % (path, e.token)) if shape == "Expense" and (o["recurring_template_id"] is None) != (o["recurring_period"] is None): errs.append("%s: recurring_template_id and recurring_period must be both null or both set" % path) if shape in ("Settlement", "PushedSettlement"): al = o["allocations"] if not sorted_by(al, "account_id"): errs.append("%s.allocations: not sorted by account_id" % path) try: validate_settlement(o["amount_ore"], o["from_user_id"], o["to_user_id"], [o["from_user_id"], o["to_user_id"]], al) except DomainError as e: errs.append("%s: %s" % (path, e.token)) if shape in ("Account", "CreateAccountRequest", "PatchAccountRequest") and "default_shares" in o: ds = o["default_shares"] if not sorted_by(ds, "user_id"): errs.append("%s.default_shares: not sorted by user_id" % path) if sum(s["share_bp"] for s in ds) != 10000 or any(not 0 <= s["share_bp"] <= 10000 for s in ds): errs.append("%s.default_shares: bp must be 0..10000 and sum to 10000" % path) if shape in ("Category", "CreateCategoryRequest", "PatchCategoryRequest"): if "icon" in o and o["icon"] not in ICONS: errs.append("%s.icon: %r is not a §9.7 icon token" % (path, o["icon"])) if "color" in o and o["color"] not in COLORS: errs.append("%s.color: %r is not a §9.7 colour token" % (path, o["color"])) if shape == "Category" and o["name"] is None and o["default_key"] is None: errs.append("%s: name and default_key cannot both be null" % path) if shape in ("RecurringTemplate", "CreateRecurringTemplateRequest", "PatchRecurringTemplateRequest") and "splits" in o: mode, sp = o.get("split_mode"), o["splits"] if not sorted_by(sp, "user_id"): errs.append("%s.splits: not sorted by user_id" % path) for s in sp: if (s["share_bp"] is None) == (s["share_ore"] is None): errs.append("%s.splits: exactly one of share_bp / share_ore must be set" % path) if mode == "default" and sp: errs.append("%s.splits: must be empty for mode default" % path) if mode in ("ratio", "full") and (len(sp) != 2 or any(s["share_bp"] is None for s in sp) or sum(s["share_bp"] or 0 for s in sp) != 10000): errs.append("%s.splits: two bp entries summing to 10000 required for mode %s" % (path, mode)) if mode == "exact" and (len(sp) != 2 or any(s["share_ore"] is None for s in sp) or sum(s["share_ore"] or 0 for s in sp) != o.get("amount_ore")): errs.append("%s.splits: two öre entries summing to the amount required for mode exact" % path) if shape in ("AcceptedExpense", "AcceptedSettlement"): if o["entity"]["id"] != o["entity_id"] or o["entity"]["server_version"] != o["server_version"]: errs.append("%s: entity id / server_version do not match the result" % path) if shape in ("RejectedExpense", "RejectedSettlement") and o["current"] is not None and o["current"]["id"] != o["entity_id"]: errs.append("%s: current.id does not match entity_id" % path) if shape == "SyncPullResponse": for k in ("users", "categories", "accounts", "recurring_templates", "expenses", "settlements", "audit_entries"): if not sorted_by(o[k], "server_version"): errs.append("%s.%s: not sorted by server_version" % (path, k)) versions = [r["server_version"] for k in ("users", "categories", "accounts", "recurring_templates", "expenses", "settlements", "audit_entries") for r in o[k]] if versions and max(versions) != o["next_since"]: errs.append("%s: next_since must be the highest server_version in the page" % path) if not versions and o["next_since"] != o["since"]: errs.append("%s: next_since must equal since for an empty page" % path) if any(v <= o["since"] for v in versions): errs.append("%s: rows must have server_version > since" % path) if shape == "AcceptInviteResponse" and not re.match(r"^kvt_[A-Za-z0-9_-]{43}$", o["token"]): errs.append("%s.token: must be kvt_ + 43 base64url characters (§8.2)" % path) if shape == "Invite" and o["code"] is not None and not re.match(r"^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{12}$", o["code"]): errs.append("%s.code: must be 12 Crockford base32 characters (§8.3)" % path) if shape == "FcmData": if o["type"] == "sync_batch": if o["entity_type"] != "" or o["entity_id"] != "" or not re.match(r"^[1-9][0-9]*$", o["count"]): errs.append("%s: sync_batch needs empty entity_type and entity_id and a positive count" % path) else: want = "settlement" if o["type"].startswith("settlement") else "expense" if o["entity_type"] != want or not UUID_RE.match(o["entity_id"]) or o["count"] != "1": errs.append("%s: %s needs entity_type %s, a uuid entity_id and count \"1\"" % (path, o["type"], want)) EXAMPLES = { "E-1-health/response.json": "Health", "E-2-setup-status/response.json": "SetupStatus", "E-3-invites-accept/request.json": "AcceptInviteRequest", "E-3-invites-accept/response.json": "AcceptInviteResponse", "E-5-apk-latest/response.json": "ApkLatest", "E-7-me/response.json": "MeResponse", "E-8-patch-me/request.json": "PatchMeRequest", "E-8-patch-me/response.json": "User", "E-9-put-me-picture/response.json": "User", "E-10-delete-me-picture/response.json": "User", "E-12-create-invite/request.json": "CreateInviteRequest", "E-12-create-invite/response.json": "Invite", "E-13-list-invites/response.json": "InviteList", "E-15-list-devices/response.json": "DeviceList", "E-17-push-token/request.json": "PushTokenRequest", "E-18-create-account/request.json": "CreateAccountRequest", "E-18-create-account/response.json": "Account", "E-19-patch-account/request.json": "PatchAccountRequest", "E-19-patch-account/response.json": "Account", "E-20-archive-account/response.json": "Account", "E-21-unarchive-account/response.json": "Account", "E-22-delete-account/response.json": "Account", "E-23-create-category/request.json": "CreateCategoryRequest", "E-23-create-category/response.json": "Category", "E-24-patch-category/request.json": "PatchCategoryRequest", "E-24-patch-category/response.json": "Category", "E-25-archive-category/response.json": "Category", "E-26-unarchive-category/response.json": "Category", "E-26-unarchive-category/response-default.json": "Category", "E-27-delete-category/response.json": "Category", "E-28-create-recurring-template/request.json": "CreateRecurringTemplateRequest", "E-28-create-recurring-template/response.json": "RecurringTemplate", "E-29-patch-recurring-template/request.json": "PatchRecurringTemplateRequest", "E-29-patch-recurring-template/response.json": "RecurringTemplate", "E-30-delete-recurring-template/response.json": "RecurringTemplate", "E-31-sync-pull/response.json": "SyncPullResponse", "E-32-sync-push/request.json": "SyncPushRequest", "E-32-sync-push/response.json": "SyncPushResponse", "E-33-settlement-proposal/request.json": "ProposalRequest", "E-33-settlement-proposal/response.json": "ProposalResponse", "sync/pull-page.json": "SyncPullResponse", "sync/pull-empty-page.json": "SyncPullResponse", "sync/pull-page-categories-v2.json": "SyncPullResponse", "sync/push-request.json": "SyncPushRequest", "sync/push-response.json": "SyncPushResponse", "sync/push-request-delete.json": "SyncPushRequest", "sync/push-response-delete.json": "SyncPushResponse", "sync/push-request-rejections.json": "SyncPushRequest", "sync/push-response-rejections.json": "SyncPushResponse", } EXAMPLES.update({"errors/%s.json" % c: "Error" for c in ERROR_CODES}) EXAMPLES.update({"push/%s.json" % t: "FcmMessage" for t in PUSH_TYPES}) def check_examples(): d = os.path.join(CONTRACTS, "examples") present = set() for dirpath, _, files in os.walk(d): for f in files: present.add(os.path.relpath(os.path.join(dirpath, f), d).replace(os.sep, "/")) missing, extra = sorted(set(EXAMPLES) - present), sorted(present - set(EXAMPLES)) report(not missing and not extra, "examples: file set (one per JSON body of §14.3.4, error code, sync and push payload)", "missing %s, unmapped %s" % (missing, extra) if (missing or extra) else "%d files" % len(present)) seen_reasons, seen_status = set(), set() def order(s): m = re.match(r"^E-(\d+)-", s) return (0, int(m.group(1)), s) if m else (1, 0, s) for name in sorted(set(EXAMPLES) & present, key=order): shape = EXAMPLES[name] try: with open(os.path.join(d, name), encoding="utf-8") as f: doc = json.load(f) except ValueError as e: report(False, "example %s" % name, "does not parse: %s" % e) continue errs, seen = [], [] validate(doc, shape, "$", errs, seen) if not errs: for sh, o, p in seen: semantic(sh, o, p, errs) if sh.startswith("Rejected"): seen_reasons.add(o["reason"]) if sh.startswith("Accepted") or sh.startswith("Rejected"): seen_status.add(o["status"]) if name.startswith("errors/") and not errs and doc["code"] != name[len("errors/"):-len(".json")]: errs.append("code does not match the file name") if name.startswith("push/") and not errs and doc["message"]["data"]["type"] != name[len("push/"):-len(".json")]: errs.append("type does not match the file name") report(not errs, "example %s matches %s" % (name, shape), "; ".join(errs[:5])) reasons = set(REASON[1]) report(seen_reasons == reasons and seen_status == {"accepted", "rejected"}, "examples: every push result status and rejection reason of §14.4.6 appears", "missing %s" % sorted((reasons - seen_reasons) | ({"accepted", "rejected"} - seen_status)) if seen_reasons != reasons else "") # ---------------------------------------------------------------------------------------------------------------------- # SQLite schema (§14.1) # ---------------------------------------------------------------------------------------------------------------------- def check_schema(): path = os.path.join(CONTRACTS, "schema", "server-v1.sql") if not os.path.exists(path): report(False, "schema server-v1.sql exists", "missing") return with open(path, encoding="utf-8") as f: sql = f.read() with open(os.path.join(ROOT, "SPEC.md"), encoding="utf-8") as f: spec = f.read() try: i = spec.index("### 14.1 SQLite schema v1") j = spec.index("```sql\n", i) + len("```sql\n") block = spec[j:spec.index("```\n", j)] report(block == sql, "schema server-v1.sql is byte-identical to the SPEC §14.1 DDL", "differs from SPEC §14.1" if block != sql else "%d bytes" % len(sql.encode("utf-8"))) except ValueError: report(False, "schema server-v1.sql is byte-identical to the SPEC §14.1 DDL", "§14.1 sql block not found in SPEC.md") if sqlite3.sqlite_version_info < (3, 37, 0): report(False, "schema server-v1.sql loads in SQLite", "SQLite %s is older than 3.37 (STRICT tables)" % sqlite3.sqlite_version) return db = sqlite3.connect(":memory:") try: db.execute("PRAGMA foreign_keys = ON") db.executescript(sql) report(True, "schema server-v1.sql loads in SQLite", "SQLite %s" % sqlite3.sqlite_version) except sqlite3.Error as e: report(False, "schema server-v1.sql loads in SQLite", str(e)) return fk = db.execute("PRAGMA foreign_key_check").fetchall() report(not fk, "schema PRAGMA foreign_key_check is clean", "%r" % fk if fk else "") tables = set(r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")) want = {"meta", "sync_sequence", "users", "devices", "invites", "accounts", "account_default_shares", "categories", "recurring_templates", "recurring_template_splits", "expenses", "expense_splits", "settlements", "settlement_allocations", "audit_log", "sync_mutations"} report(tables == want, "schema tables are the 16 of §14.1", "got %s" % sorted(tables) if tables != want else "") meta = dict(db.execute("SELECT key, value FROM meta")) seq = db.execute("SELECT id, value FROM sync_sequence").fetchall() cats = db.execute("SELECT default_key, icon, color, sort_order, server_version FROM categories ORDER BY sort_order").fetchall() want_cats = [("groceries", "shopping_cart", "green", 10, 1), ("restaurants", "restaurant", "orange", 20, 2), ("home", "home", "brown", 30, 3), ("utilities", "bolt", "yellow", 40, 4), ("transport", "directions_car", "blue", 50, 5), ("travel", "flight", "teal", 60, 6), ("entertainment", "movie", "purple", 70, 7), ("health", "medical_services", "red", 80, 8), ("shopping", "shopping_bag", "pink", 90, 9), ("other", "category", "grey", 100, 10)] ok = (meta.get("schema_version") == "1" and re.match(r"^[0-9a-f]{32}$", meta.get("sync_epoch", "")) is not None and seq == [(1, 10)] and cats == want_cats) report(ok, "schema seed rows (schema_version 1, sync_epoch, sync_sequence 10, ten default categories of AC-7.2)", "meta %r, sequence %r, categories %r" % (meta, seq, cats) if not ok else "") db.close() V1_DEFAULT_CATEGORIES = [ ("01920000-0000-7000-8000-000000000001", "groceries", "shopping_cart", "green", 10), ("01920000-0000-7000-8000-000000000002", "restaurants", "restaurant", "orange", 20), ("01920000-0000-7000-8000-000000000003", "home", "home", "brown", 30), ("01920000-0000-7000-8000-000000000004", "utilities", "bolt", "yellow", 40), ("01920000-0000-7000-8000-000000000005", "transport", "directions_car", "blue", 50), ("01920000-0000-7000-8000-000000000006", "travel", "flight", "teal", 60), ("01920000-0000-7000-8000-000000000007", "entertainment", "movie", "purple", 70), ("01920000-0000-7000-8000-000000000008", "health", "medical_services", "red", 80), ("01920000-0000-7000-8000-000000000009", "shopping", "shopping_bag", "pink", 90), ("01920000-0000-7000-8000-00000000000a", "other", "category", "grey", 100), ] V2_NEW_CATEGORIES = [ ("01920000-0000-7000-8000-000000000011", "housing", "key", "indigo", 25), ("01920000-0000-7000-8000-000000000012", "insurance", "shield", "slate", 45), ("01920000-0000-7000-8000-000000000013", "children", "child_care", "lime", 85), ("01920000-0000-7000-8000-000000000014", "fees", "account_balance", "amber", 95), ] MIGRATION_0002 = os.path.join("schema", "migrations", "0002_categories_v2.sql") def _migrate_0002(db, sql): """Applies 0002 the way sqlx does: one transaction, foreign keys on (set before it starts).""" db.isolation_level = None db.execute("BEGIN") try: for stmt in _split_sql(sql): db.execute(stmt) db.execute("COMMIT") except sqlite3.Error: db.execute("ROLLBACK") raise def _split_sql(sql): out, buf = [], "" for line in sql.splitlines(True): buf += line if sqlite3.complete_statement(buf): out.append(buf) buf = "" if buf.strip() and not all(l.strip().startswith("--") or not l.strip() for l in buf.splitlines()): out.append(buf) return out def check_migration_0002(): path = os.path.join(CONTRACTS, MIGRATION_0002) v1 = os.path.join(CONTRACTS, "schema", "server-v1.sql") if not os.path.exists(path): report(False, "schema 0002_categories_v2.sql exists", "missing") return with open(path, encoding="utf-8") as f: sql = f.read() with open(v1, encoding="utf-8") as f: v1sql = f.read() if re.search(r"^\s*(BEGIN|COMMIT|END|ROLLBACK)\b", sql, re.M | re.I) or re.search(r"PRAGMA\s+foreign_keys\b", sql, re.I): report(False, "schema 0002 has no transaction control or foreign_keys pragma (sqlx wraps it)", "found one") else: report(True, "schema 0002 has no transaction control or foreign_keys pragma (sqlx wraps it)") def fresh(): db = sqlite3.connect(":memory:") db.execute("PRAGMA foreign_keys = ON") db.executescript(v1sql) return db # 1. empty v1 database db = fresh() try: _migrate_0002(db, sql) except sqlite3.Error as e: report(False, "schema 0002 loads on top of server-v1.sql (foreign keys on, one transaction)", str(e)) return fk = db.execute("PRAGMA foreign_key_check").fetchall() integ = db.execute("PRAGMA integrity_check").fetchone()[0] report(not fk and integ == "ok", "schema 0002 loads on top of server-v1.sql (foreign keys on, one transaction)", "foreign_key_check %r, integrity %s" % (fk, integ) if (fk or integ != "ok") else "SQLite %s" % sqlite3.sqlite_version) meta = dict(db.execute("SELECT key, value FROM meta")) seq = db.execute("SELECT id, value FROM sync_sequence").fetchall() rows = db.execute("SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, " "deleted_at, server_version FROM categories ORDER BY sort_order").fetchall() want = sorted([(i, k, None, ic, co, so, "2026-10-17T00:00:00.000Z", "2026-10-17T00:00:00.000Z", None, None, n + 1) for n, (i, k, ic, co, so) in enumerate(V1_DEFAULT_CATEGORIES)] + [(i, k, None, ic, co, so, "2026-10-18T00:00:00.000Z", "2026-10-18T00:00:00.000Z", None, None, 11 + n) for n, (i, k, ic, co, so) in enumerate(V2_NEW_CATEGORIES)], key=lambda r: r[5]) ok = meta.get("schema_version") == "2" and seq == [(1, 14)] and rows == want report(ok, "schema 0002 on a fresh database: 14 default categories, new server_versions 11..14, sync_sequence 14, schema_version 2", "meta %r, sequence %r, categories %r" % (meta, seq, rows) if not ok else "") keys = [r[1] for r in rows] report(sorted(keys) == sorted(DEFAULT_KEYS) and all(r[3] in ICONS and r[4] in COLORS for r in rows), "schema 0002 default keys, icons and colours are the checker's §9.7 / AC-7.2 lists", "keys %r" % keys) try: db.execute("INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, server_version) " "VALUES ('x', 'pets', NULL, 'pets', 'lime', 1, 't', 't', 999)") report(False, "schema 0002 keeps a default_key CHECK", "an unknown default_key was accepted") except sqlite3.IntegrityError: report(True, "schema 0002 keeps a default_key CHECK") seed_sql = ( "INSERT INTO users VALUES ('u1', 'admin', 'A', NULL, 't', 't', 11);" "INSERT INTO accounts VALUES ('a1', 'Vardag', 'vardag', 'u1', 't', 't', NULL, NULL, 12);" "INSERT INTO categories VALUES ('0192a007-0000-7000-8000-000000000001', NULL, 'Husdjur', 'pets', 'lime', 110, 't', 't', 't', NULL, 13);" "INSERT INTO expenses (id, account_id, category_id, amount_ore, description, expense_date, payer_user_id, split_mode," " created_by_user_id, updated_by_user_id, created_at, updated_at, server_received_at, server_version)" " VALUES ('e1', 'a1', '01920000-0000-7000-8000-000000000001', 100, 'Ica', '2026-10-01', 'u1', 'default', 'u1', 'u1', 't', 't', 't', 14)," " ('e2', 'a1', '0192a007-0000-7000-8000-000000000001', 200, 'Hundmat', '2026-10-02', 'u1', 'default', 'u1', 'u1', 't', 't', 't', 15);" "INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, payer_user_id, split_mode, day_of_month," " start_period, created_by_user_id, updated_by_user_id, created_at, updated_at, server_version)" " VALUES ('r1', 'a1', '01920000-0000-7000-8000-000000000004', 300, 'El', 'u1', 'default', 1, '2026-10', 'u1', 'u1', 't', 't', 16);" "UPDATE categories SET name = 'Matvaror', server_version = 17 WHERE default_key = 'groceries';" "UPDATE sync_sequence SET value = 917 WHERE id = 1;") db.close() # 2. a v1 database in use: renamed default, custom archived category, expenses and a template referring to categories db = fresh() db.executescript(seed_sql) before = db.execute("SELECT * FROM categories ORDER BY id").fetchall() try: _migrate_0002(db, sql) fk = db.execute("PRAGMA foreign_key_check").fetchall() after = db.execute("SELECT * FROM categories WHERE id NOT IN (%s) ORDER BY id" % ",".join("'%s'" % c[0] for c in V2_NEW_CATEGORIES)).fetchall() new = db.execute("SELECT default_key, server_version FROM categories WHERE created_at = '2026-10-18T00:00:00.000Z' ORDER BY server_version").fetchall() seq = db.execute("SELECT value FROM sync_sequence").fetchone()[0] refs = db.execute("SELECT count(*) FROM expenses e JOIN categories c ON c.id = e.category_id").fetchone()[0] defer = db.execute("PRAGMA defer_foreign_keys").fetchone()[0] ok = (not fk and after == before and new == [("housing", 918), ("insurance", 919), ("children", 920), ("fees", 921)] and seq == 921 and refs == 2 and defer == 0) report(ok, "schema 0002 on a database in use: rows kept, references intact, new rows take 918..921 from the sequence", "fk %r, kept %s, new %r, sequence %r, refs %r, defer %r" % (fk, after == before, new, seq, refs, defer) if not ok else "") except sqlite3.Error as e: report(False, "schema 0002 on a database in use: rows kept, references intact, new rows take 918..921 from the sequence", str(e)) db.close() # 3. the deferred check still catches a dangling reference: drop the referenced row inside the same transaction db = fresh() db.executescript(seed_sql) try: _migrate_0002(db, sql + "\nDELETE FROM categories WHERE id = '0192a007-0000-7000-8000-000000000001';\n") report(False, "schema 0002 keeps foreign keys enforced (a dangling reference fails the migration)", "a dangling expense reference was committed") except sqlite3.IntegrityError: report(True, "schema 0002 keeps foreign keys enforced (a dangling reference fails the migration)") db.close() server_copy = os.path.join(ROOT, "server", "crates", "kvitt-server", "migrations", "0002_categories_v2.sql") if os.path.exists(server_copy): with open(server_copy, encoding="utf-8") as f: same = f.read() == sql report(same, "schema server migrations/0002_categories_v2.sql is byte-identical to the contract", "" if same else "differs") # ---------------------------------------------------------------------------------------------------------------------- # Keyword table of the category suggestion (UX-3, CS-6) # ---------------------------------------------------------------------------------------------------------------------- KEYWORDS_CSV = "category-keywords.csv" KEYWORDS_HEADER = ["keyword", "match", "default_key", "source"] def load_keywords(): path = os.path.join(CONTRACTS, KEYWORDS_CSV) if not os.path.exists(path): return None, None with open(path, "rb") as f: raw = f.read() return raw, list(csv.reader(io.StringIO(raw.decode("utf-8"), newline=""))) def check_keywords(): raw, rows = load_keywords() if raw is None: report(False, "keywords %s exists" % KEYWORDS_CSV, "missing") return [] problems = [] if raw.startswith(b"\xef\xbb\xbf") or b"\r" in raw or not raw.endswith(b"\n") or raw.endswith(b"\n\n"): problems.append("must be UTF-8 without BOM, LF line ends, one final newline") if not rows or rows[0] != KEYWORDS_HEADER: problems.append("header must be %s" % ",".join(KEYWORDS_HEADER)) entries = [] for n, r in enumerate(rows[1:], start=2): if len(r) != 4: problems.append("line %d: %d columns" % (n, len(r))) continue kw, kind, key, source = r if kind not in KEYWORD_MATCHES: problems.append("line %d: match %r" % (n, kind)) if key not in DEFAULT_KEYS or key == "other": problems.append("line %d: default_key %r (one of the 14 keys, never other)" % (n, key)) if source not in ("data", "curated"): problems.append("line %d: source %r" % (n, source)) if not kw or normalize_description(kw) != kw: problems.append("line %d: keyword %r is not normalised (CS-4 gives %r)" % (n, kw, normalize_description(kw))) if (kind == "phrase") != (" " in kw): problems.append("line %d: %r: a phrase has two or more tokens, a token or prefix exactly one" % (n, kw)) entries.append({"keyword": kw, "match": kind, "default_key": key, "source": source}) kws = [e["keyword"] for e in entries] dup = sorted(set(k for k in kws if kws.count(k) > 1)) if dup: problems.append("duplicate keywords %s" % dup) order = [(e["default_key"], e["keyword"]) for e in entries] if order != sorted(order): bad = [b for a, b in zip(order, order[1:]) if a > b] problems.append("not sorted by default_key, then keyword (code points); first out of order %r" % (bad[:1],)) unused = sorted(set(k for k in DEFAULT_KEYS if k != "other") - set(e["default_key"] for e in entries)) detail = "%d entries (%s), every default key but other covered%s" % ( len(entries), ", ".join("%d %s" % (sum(1 for e in entries if e["match"] == m), m) for m in KEYWORD_MATCHES), "" if not unused else "; keys without entries: %s" % unused) report(not problems, "keywords %s: header, match kinds, default keys, normalised, unique, sorted" % KEYWORDS_CSV, "; ".join(problems[:6]) if problems else detail) return entries def check_vector_keywords(table): """Every keyword list in suggest_category.json is a subsequence of the table (same entries, table order).""" path = os.path.join(CONTRACTS, "test-vectors", "suggest_category.json") if not table or not os.path.exists(path): return try: with open(path, encoding="utf-8") as f: cases = json.load(f)["cases"] except (ValueError, KeyError, TypeError): return index = dict((e["keyword"], (n, e["match"], e["default_key"])) for n, e in enumerate(table)) bad = [] for c in cases: try: pos = [index.get(k["keyword"]) for k in c["input"]["keywords"]] ok = (all(p is not None and (p[1], p[2]) == (k["match"], k["default_key"]) for p, k in zip(pos, c["input"]["keywords"])) and [p[0] for p in pos] == sorted(set(p[0] for p in pos))) except (KeyError, TypeError): ok = False if not ok: bad.append(c.get("name")) report(not bad, "vectors suggest_category.json keyword lists are taken from %s in table order" % KEYWORDS_CSV, "cases %s" % bad[:5] if bad else "%d cases" % len(cases)) # ---------------------------------------------------------------------------------------------------------------------- # OpenAPI (§7, §14.3) — text-level check # ---------------------------------------------------------------------------------------------------------------------- ENDPOINTS = [ ("E-1", "get", "/health"), ("E-2", "get", "/setup/status"), ("E-3", "post", "/invites/accept"), ("E-4", "get", "/i/{code}"), ("E-5", "get", "/apk/latest"), ("E-6", "get", "/apk/files/{file_name}"), ("E-7", "get", "/me"), ("E-8", "patch", "/me"), ("E-9", "put", "/me/picture"), ("E-10", "delete", "/me/picture"), ("E-11", "get", "/users/{user_id}/picture"), ("E-12", "post", "/invites"), ("E-13", "get", "/invites"), ("E-14", "delete", "/invites/{invite_id}"), ("E-15", "get", "/devices"), ("E-16", "delete", "/devices/{device_id}"), ("E-17", "put", "/devices/current/push-token"), ("E-18", "post", "/accounts"), ("E-19", "patch", "/accounts/{account_id}"), ("E-20", "post", "/accounts/{account_id}/archive"), ("E-21", "post", "/accounts/{account_id}/unarchive"), ("E-22", "delete", "/accounts/{account_id}"), ("E-23", "post", "/categories"), ("E-24", "patch", "/categories/{category_id}"), ("E-25", "post", "/categories/{category_id}/archive"), ("E-26", "post", "/categories/{category_id}/unarchive"), ("E-27", "delete", "/categories/{category_id}"), ("E-28", "post", "/recurring-templates"), ("E-29", "patch", "/recurring-templates/{template_id}"), ("E-30", "delete", "/recurring-templates/{template_id}"), ("E-31", "get", "/sync"), ("E-32", "post", "/sync"), ("E-33", "post", "/settlements/proposal"), ] UNAUTHENTICATED = {"E-1", "E-2", "E-3", "E-4", "E-5", "E-6"} def check_openapi(): path = os.path.join(CONTRACTS, "openapi.yaml") if not os.path.exists(path): report(False, "openapi.yaml exists", "missing") return with open(path, encoding="utf-8") as f: lines = f.read().splitlines() text = "\n".join(lines) head_ok = (re.search(r"^openapi: 3\.1\.0$", text, re.M) and re.search(r"^ title: Kvitt$", text, re.M) and re.search(r"^ version: '1'$", text, re.M) and re.search(r"^ - url: https://\{host\}:8443\n variables:\n host:\n default: kvitt\.example\.invalid$", text, re.M)) report(bool(head_ok), "openapi header (3.1.0, title Kvitt, version 1, one server)", "" if head_ok else "header fields differ from §14.3") sec_ok = re.search(r"^ device:\n type: http\n scheme: bearer$", text, re.M) is not None report(sec_ok, "openapi security scheme device = HTTP bearer (§8.2)", "" if sec_ok else "components.securitySchemes.device not found") # paths: path keys at indent 2, method keys at indent 4, each operation carries `summary: E-n …` ops, in_paths, cur_path, cur_method = {}, False, None, None for ln in lines: if re.match(r"^\S", ln): in_paths = ln.startswith("paths:") continue if not in_paths: continue m = re.match(r"^ (/\S*):\s*$", ln) if m: cur_path = m.group(1) continue m = re.match(r"^ (get|put|post|patch|delete|head|options|trace):\s*$", ln) if m and cur_path: cur_method = m.group(1) ops[(cur_method, cur_path)] = {"eid": None, "security": None} continue m = re.match(r"^ summary: '?(E-\d+)\b", ln) if m and cur_method: ops[(cur_method, cur_path)]["eid"] = m.group(1) m = re.match(r"^ security: (.*)$", ln) if m and cur_method: ops[(cur_method, cur_path)]["security"] = m.group(1).strip() want = {(meth, p): eid for eid, meth, p in ENDPOINTS} got = {k: v["eid"] for k, v in ops.items()} report(got == want, "openapi operations are exactly E-1..E-33 of §7", "missing %s, extra %s, wrong ids %s" % (sorted(set(want) - set(got)), sorted(set(got) - set(want)), sorted(k for k in want if k in got and got[k] != want[k])) if got != want else "%d operations" % len(got)) bad_sec = sorted(eid for (meth, p), eid in want.items() if (meth, p) in ops and ((ops[(meth, p)]["security"] == "[]") != (eid in UNAUTHENTICATED))) report(not bad_sec, "openapi security: E-1..E-6 unauthenticated, all others device", "wrong: %s" % bad_sec if bad_sec else "") m = re.search(r"^ ErrorCode:\n((?: .*\n|\s*\n)+)", text + "\n", re.M) codes = re.findall(r"^ - (\w+)$", m.group(1), re.M) if m else [] report(codes == ERROR_CODES, "openapi ErrorCode enum is the exhaustive list of §14.3.2", "got %s" % codes if codes != ERROR_CODES else "%d codes" % len(codes)) m = re.search(r"^ RejectionReason:\n((?: .*\n|\s*\n)+)", text + "\n", re.M) reasons = re.findall(r"^ - (\w+)$", m.group(1), re.M) if m else [] report(reasons == REASON[1], "openapi RejectionReason enum is the exhaustive list of §14.4.6", "got %s" % reasons if reasons != REASON[1] else "") m = re.search(r"^ DefaultCategoryKey:\n type: string\n enum: \[([^\]]*)\]$", text, re.M) keys = [k.strip() for k in m.group(1).split(",")] if m else [] report(keys == DEFAULT_KEYS, "openapi DefaultCategoryKey enum is the 14 default keys (AC-7.2, UX-8)", "got %s" % keys if keys != DEFAULT_KEYS else "%d keys" % len(keys)) leaked = [t for t in ("ColorToken", "IconToken") if re.search(r"^ %s:" % t, text, re.M)] icon_plain = re.search(r"^ icon:\n type: string\n", text, re.M) is not None color_plain = re.search(r"^ color:\n type: string\n", text, re.M) is not None report(not leaked and icon_plain and color_plain, "openapi icon and color stay plain strings (§9.7 tokens, incl. key, shield, account_balance, slate, amber, are checked in code)", "token enums %s, icon plain %s, color plain %s" % (leaked, icon_plain, color_plain) if (leaked or not icon_plain or not color_plain) else "") schemas = set(re.findall(r"^ ([A-Z]\w*):$", text, re.M)) need = {"User", "Device", "Invite", "Share", "Account", "Category", "Split", "TemplateSplit", "Expense", "Allocation", "Settlement", "RecurringTemplate", "AuditEntry", "Error"} report(need <= schemas, "openapi declares every wire object of §14.3.3", "missing %s" % sorted(need - schemas) if not need <= schemas else "") # ---------------------------------------------------------------------------------------------------------------------- def check_json_parses(): bad, n = [], 0 for dirpath, _, files in os.walk(CONTRACTS): for f in files: if f.endswith(".json"): n += 1 p = os.path.join(dirpath, f) try: with open(p, encoding="utf-8") as fh: json.load(fh) except ValueError as e: bad.append("%s (%s)" % (rel(p), e)) report(not bad and n > 0, "json: every file under contracts/ parses", "; ".join(bad) if bad else "%d files" % n) def main(): if not os.path.isdir(CONTRACTS): report(False, "contracts/ exists", "no such directory: %s" % CONTRACTS) else: check_json_parses() counts = check_vectors() table = check_keywords() check_vector_keywords(table) check_examples() check_schema() check_migration_0002() check_openapi() if counts: print("vector cases: " + ", ".join("%s %d" % (k, v) for k, v in sorted(counts.items()))) print("%d checks passed, %d failed" % (PASSES[0], len(FAILURES))) return 1 if FAILURES else 0 if __name__ == "__main__": sys.exit(main())