proto: freeze the wire contract at 1.0.0

Schemas for the whole v1 surface: 38 methods, 9 events, 25 named types and the
JSON-RPC envelope, with x-privileged / x-transports / x-deadlineMs / x-errors
annotations that both generators emit as data rather than prose.

Four generators over one IR (contracts/codegen/schema_ir.py), so the C++ structs,
the TypeScript types and the OpenRPC document cannot disagree about what the
contract says:

  gen_cpp.py             -> core/generated/velox_proto.{hpp,cpp}
  gen_ts.py              -> extension/src/shared/protocol/
  gen_openrpc.py         -> contracts/openrpc.json
  gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp

Inbound parsing never throws: parse<T>() returns std::expected<T, ParseError> and
nlohmann's throwing ADL from_json is deliberately not emitted. Schema constraints
(minimum, maxLength, pattern, ...) become real runtime checks in both languages —
the daemon does not trust the extension and the extension does not trust the
daemon.

59 golden fixtures: a success case per method, 12 error cases, 9 events. Replayed
by tests/conformance/ against both the generated C++ and a live server over both
transports. tools/mockd serves the same fixtures with unhappy-path flags so the
GUI and EXT lanes never wait for veloxd.

run.sh also proves capture.offer fails open: with a daemon answering slower than
750 ms the client gives up and lets Firefox take the download.

core/generated/ is libveloxproto, a separate target from libveloxcore, which
still never sees JSON — see docs/adr/0009.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
This commit is contained in:
2026-09-09 19:55:54 +04:00
co-authored by Claude Opus 5
parent a40585f419
commit 53421d6cb8
171 changed files with 29275 additions and 51 deletions
+64
View File
@@ -0,0 +1,64 @@
# contracts/codegen — the generators
Four generators, one IR. Everything is derived from `contracts/schema/`; nothing here is
a second source of truth.
```
schema_ir.py loads schema/ and lowers it to a small IR
├── gen_cpp.py -> core/generated/velox_proto.{hpp,cpp}
├── gen_ts.py -> extension/src/shared/protocol/*.ts
├── gen_openrpc.py -> contracts/openrpc.json
└── gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp
```
Regenerate everything:
```sh
for g in gen_cpp gen_ts gen_openrpc gen_cpp_conformance; do
python3 contracts/codegen/$g.py
done
```
`tests/conformance/check_contract.py` re-runs all four and fails if any committed output
differs, so stale generated code cannot be merged.
## The supported JSON Schema subset
The generators refuse to guess. Anything outside this subset raises `SchemaError` at
generation time rather than emitting subtly wrong code — a schema that cannot be generated
from is a contract bug, and it should stop the build.
| Supported | Emitted as |
|---|---|
| `object` + `properties` | struct / interface |
| `object` + typed `additionalProperties` | `std::map<std::string, T>` / `Record<string, T>` |
| `string` + `enum` | `enum class` / string-literal union |
| `integer` + `enum` + `x-enum` | `enum class : int32_t` / `as const` object |
| `array` + `items` | `std::vector<T>` / `T[]` |
| `$ref` to a `types/*.schema.json` | the named type |
| `["X", "null"]`, or `oneOf: [X, {type: null}]` | `std::optional<T>` / `T \| null` |
| `{}` | `nlohmann::json` / `unknown` |
| `minimum` `maximum` `minLength` `maxLength` `pattern` `minItems` `maxItems` | runtime checks in both languages |
Deliberately unsupported: `allOf`, `anyOf`, general `oneOf`, `patternProperties`, tuple
`items`, recursive types. If the contract needs one, extend `schema_ir.py` in the same PR
that needs it.
## Two rules the generated code follows
**Nothing throws on the inbound path.** `gen_cpp.py` emits `parse<T>() ->
std::expected<T, ParseError>` and deliberately does *not* emit nlohmann's ADL `from_json`,
whose failure mode is an exception. A malformed frame off the wire is an ordinary value the
RPC loop handles, not a throw unwinding through the daemon.
**Constraints are checked, not just documented.** A `maximum` in a schema becomes an `if`
in both languages. The daemon is not allowed to trust the extension and the extension is
not allowed to trust the daemon — `ws://127.0.0.1` is reachable by every local process, so
a type declaration proves nothing at runtime.
## Absent and null mean the same thing
Both generators treat a missing field and an explicit `null` identically. A client that
omits a nullable field and one that sends `null` get the same result, in both languages.
This is stated here because it is the kind of asymmetry that otherwise surfaces as a
cross-language bug six months later.
+604
View File
@@ -0,0 +1,604 @@
#!/usr/bin/env python3
"""Emit core/generated/velox_proto.{hpp,cpp} from contracts/schema/.
Design notes that matter to the CORE and DAEMON lanes:
* **No exceptions.** nlohmann's own throwing `get<T>()` / ADL `from_json` are deliberately
not used and not emitted. Parsing goes through `velox::proto::parse<T>(json)` which
returns `std::expected<T, ParseError>`, so a malformed frame from the wire is an ordinary
value the RPC loop handles, not a throw unwinding through the transfer path.
* **Serialisation is ADL `to_json`,** so `nlohmann::json j = task;` works as expected.
Only the outbound direction is allowed to be implicit; the wire is never trusted.
* **`libveloxproto`, not `libveloxcore`.** This code includes nlohmann/json, which
CLAUDE.md forbids inside core. It is a separate target that core and daemon both link;
the layering rule constrains `libveloxcore`, and `core/generated/` is not part of it.
See docs/adr/0009-generated-protocol-library.md.
Run: python3 contracts/codegen/gen_cpp.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from schema_ir import Contract, Field, TypeDef, TypeRef, load, pascal, topo_sorted # noqa: E402
OUT_DIR = Path(__file__).resolve().parent.parent.parent / "core" / "generated"
BANNER = """// ---------------------------------------------------------------------------
// GENERATED FILE — DO NOT EDIT.
//
// Source: contracts/schema/**
// Generator: contracts/codegen/gen_cpp.py
// Contract: v{version}
//
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
// python3 contracts/codegen/gen_cpp.py
// Only lane PROTO commits to contracts/.
// ---------------------------------------------------------------------------
"""
def cpp_type(ref: TypeRef) -> str:
if ref.kind == "named":
return ref.name or "void"
if ref.kind == "string":
return "std::string"
if ref.kind == "integer":
return "std::int64_t"
if ref.kind == "number":
return "double"
if ref.kind == "boolean":
return "bool"
if ref.kind == "json":
return "nlohmann::json"
if ref.kind == "array":
return f"std::vector<{cpp_type(ref.inner)}>"
if ref.kind == "map":
return f"std::map<std::string, {cpp_type(ref.inner)}>"
raise AssertionError(ref.kind)
def cpp_constraints(ref: TypeRef, var: str, path: str, indent: str) -> list[str]:
"""Range, length and pattern checks. The wire is untrusted: a `maximum` in the schema
has to be a check here, or -32602 would never fire for an out-of-range value."""
lim = ref.limits
if not lim:
return []
i = indent
o: list[str] = []
def bail(msg: str) -> str:
return f'return std::unexpected(ParseError{{std::string({path}), "{msg}"}});'
if ref.kind in ("integer", "number"):
if "minimum" in lim:
o.append(f'{i}if ({var} < {lim["minimum"]}) {bail("value is below the minimum of " + str(lim["minimum"]))}')
if "maximum" in lim:
o.append(f'{i}if ({var} > {lim["maximum"]}) {bail("value is above the maximum of " + str(lim["maximum"]))}')
elif ref.kind == "string":
if "minLength" in lim:
o.append(f'{i}if ({var}.size() < {lim["minLength"]}u) {bail("value is shorter than " + str(lim["minLength"]) + " characters")}')
if "maxLength" in lim:
o.append(f'{i}if ({var}.size() > {lim["maxLength"]}u) {bail("value is longer than " + str(lim["maxLength"]) + " characters")}')
if "pattern" in lim:
lit = json.dumps(lim["pattern"])
o.append(f"{i}{{")
o.append(f"{i} static const std::regex re({lit}, std::regex::ECMAScript);")
o.append(f'{i} if (!std::regex_match({var}, re)) {bail("value does not match the required pattern")}')
o.append(f"{i}}}")
elif ref.kind == "array":
if "minItems" in lim:
o.append(f'{i}if ({var}.size() < {lim["minItems"]}u) {bail("fewer than " + str(lim["minItems"]) + " items")}')
if "maxItems" in lim:
o.append(f'{i}if ({var}.size() > {lim["maxItems"]}u) {bail("more than " + str(lim["maxItems"]) + " items")}')
return o
def field_type(f: Field) -> str:
inner = cpp_type(f.type)
return f"std::optional<{inner}>" if f.optional else inner
def ident(name: str) -> str:
"""A JSON property name as a C++ member name."""
out = name.replace(".", "_").replace("-", "_")
reserved = {"class", "delete", "namespace", "operator", "template", "new", "auto"}
return out + "_" if out in reserved else out
def doc_comment(text: str, indent: str = "") -> list[str]:
if not text:
return []
lines: list[str] = []
words = text.split()
cur = ""
for w in words:
if len(cur) + len(w) + 1 > 92:
lines.append(cur)
cur = w
else:
cur = f"{cur} {w}".strip()
if cur:
lines.append(cur)
return [f"{indent}/// {ln}" for ln in lines]
def method_ident(name: str) -> str:
return pascal(name)
def handler_name(name: str) -> str:
return "on_" + name.replace(".", "_")
# ------------------------------------------------------------------ header
def emit_header(c: Contract) -> str:
o: list[str] = [BANNER.format(version=c.version), "#pragma once", ""]
o += [
"#include <cstdint>",
"#include <expected>",
"#include <map>",
"#include <optional>",
"#include <string>",
"#include <string_view>",
"#include <vector>",
"",
"#include <nlohmann/json.hpp>",
"",
"// This is libveloxproto, NOT libveloxcore. The layering rule in CLAUDE.md forbids",
"// JSON inside the engine; the engine does not link this target. See",
"// docs/adr/0009-generated-protocol-library.md.",
"namespace velox::proto {",
"",
f'inline constexpr std::string_view kProtocolVersion = "{c.version}";',
"",
"/// Why a payload could not be turned into a typed value. `path` is a JSON Pointer",
"/// into the offending document, so a conformance failure names the exact field.",
"struct ParseError {",
" std::string path;",
" std::string message;",
"};",
"",
"/// Every parse in this file returns one of these. Nothing here throws.",
"template <class T>",
"using Result = std::expected<T, ParseError>;",
"",
"/// Which listener a request arrived on. Decides whether a privileged method is",
"/// allowed: see `is_allowed_on`.",
"enum class Transport { Uds, Ws };",
"",
]
# ---- enums
for t in topo_sorted(c.types):
if t.kind == "string_enum":
o += doc_comment(t.doc)
o.append(f"enum class {t.name} {{")
for v in t.values:
o.append(f" {v.name}, // \"{v.wire}\"")
o.append("};")
o.append(f"std::string_view to_string({t.name} v) noexcept;")
o.append(f"Result<{t.name}> parse_{t.name}(std::string_view s);")
o.append("")
elif t.kind == "int_enum":
o += doc_comment(t.doc)
o.append(f"enum class {t.name} : std::int32_t {{")
for v in t.values:
o += doc_comment(v.doc, " ")
o.append(f" {v.name} = {v.wire},")
o.append("};")
o.append(f"std::string_view to_string({t.name} v) noexcept;")
o.append(f"std::optional<{t.name}> {t.name.lower()}_from_int(std::int32_t v) noexcept;")
o.append("")
elif t.kind == "map_alias":
o += doc_comment(t.doc)
o.append(f"using {t.name} = {cpp_type(t.alias)};")
o.append("")
# ---- structs
for t in topo_sorted(c.types):
if t.kind != "struct":
continue
o += doc_comment(t.doc)
o.append(f"struct {t.name} {{")
if not t.fields:
o.append(" // No fields: this method takes no parameters.")
for f in t.fields:
o += doc_comment(f.doc, " ")
o.append(f" {field_type(f)} {ident(f.name)}{{}};")
o.append("};")
o.append("")
o += [
"// --- serialisation ---------------------------------------------------------",
"// ADL hooks, so `nlohmann::json j = value;` works. Outbound only: there is no",
"// generated from_json, because nlohmann's inbound path throws and the wire is",
"// never trusted. Use parse<T> below.",
"",
]
for t in topo_sorted(c.types):
if t.kind in ("struct", "string_enum", "int_enum"):
o.append(f"void to_json(nlohmann::json& j, const {t.name}& v);")
o.append("")
o += [
"// --- parsing ---------------------------------------------------------------",
"",
"/// Turn an untrusted JSON value into a typed one. Specialised below for every",
"/// contract type; the primary template is intentionally not defined, so asking",
"/// for a type the contract does not have is a compile error, not a runtime one.",
"template <class T>",
"Result<T> parse(const nlohmann::json& j, std::string_view path = \"\");",
"",
]
for t in topo_sorted(c.types):
if t.kind in ("struct", "string_enum", "int_enum", "map_alias"):
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path);")
o.append("")
# ---- method / event enums
o += [
"// --- method surface --------------------------------------------------------",
"",
"/// Every method in the contract. Generated, so a daemon cannot answer a method",
"/// the contract does not define, and cannot silently fail to answer one it does.",
"enum class Method {",
]
for m in c.methods:
o.append(f" {method_ident(m.name)}, // {m.name}")
o += ["};", "",
"inline constexpr std::size_t kMethodCount = " + str(len(c.methods)) + ";",
"",
"std::string_view to_string(Method m) noexcept;",
"std::optional<Method> method_from_string(std::string_view s) noexcept;",
"",
"/// True for methods refused over the WebSocket transport with -32003. The",
"/// extension is not allowed to reconfigure the daemon or destroy user data.",
"bool is_privileged(Method m) noexcept;",
"bool is_allowed_on(Method m, Transport t) noexcept;",
"",
"/// The contract's answer deadline. capture.offer's 750 ms is the one that",
"/// matters: past it the extension has already let Firefox take the download.",
"std::int32_t deadline_ms(Method m) noexcept;",
""]
o += ["/// Server-to-client notifications.", "enum class Event {"]
for e in c.events:
o.append(f" {pascal(e.name[len('event.'):])}, // {e.name}")
o += ["};", "",
"std::string_view to_string(Event e) noexcept;",
"std::optional<Event> event_from_string(std::string_view s) noexcept;",
"",
"// --- dispatch --------------------------------------------------------------",
"",
"/// Build a JSON-RPC error response. `id` may be null for a request that could",
"/// not be parsed far enough to have one.",
"nlohmann::json make_error(const nlohmann::json& id, ErrorCode code, std::string_view message,",
" nlohmann::json data = nullptr);",
"nlohmann::json make_result(const nlohmann::json& id, nlohmann::json result);",
"nlohmann::json make_notification(Event e, nlohmann::json params);",
"",
"/// One virtual per method. The daemon implements this; `dispatch` below does the",
"/// envelope handling, the transport check and the parameter parsing, so a handler",
"/// only ever sees a validated, typed params struct.",
"class Dispatcher {",
"public:",
" virtual ~Dispatcher() = default;",
""]
for m in c.methods:
o += doc_comment(m.doc, " ")
o.append(f" virtual Result<{cpp_type(m.result)}> {handler_name(m.name)}(const {cpp_type(m.params)}& params) = 0;")
o.append("")
o += ["};", "",
"/// Parse one JSON-RPC request, route it, and return the response to write back.",
"/// Never throws. Returns a null json for a notification that needs no reply.",
"nlohmann::json dispatch(Dispatcher& handler, Transport transport, const nlohmann::json& request);",
"",
"} // namespace velox::proto",
""]
return "\n".join(o)
# ------------------------------------------------------------------ source
def emit_source(c: Contract) -> str:
o: list[str] = [BANNER.format(version=c.version),
'#include "velox_proto.hpp"', "", "#include <algorithm>", "#include <regex>", "",
"namespace velox::proto {", "", "namespace {", "",
'std::string join(std::string_view path, std::string_view key) {',
' std::string out(path);',
' out += "/";',
' out += key;',
' return out;',
'}', "", "} // namespace", ""]
# enum conversions
for t in topo_sorted(c.types):
if t.kind == "string_enum":
o.append(f"std::string_view to_string({t.name} v) noexcept {{")
o.append(" switch (v) {")
for v in t.values:
o.append(f' case {t.name}::{v.name}: return "{v.wire}";')
o.append(" }")
o.append(' return "";')
o.append("}")
o.append("")
o.append(f"Result<{t.name}> parse_{t.name}(std::string_view s) {{")
for v in t.values:
o.append(f' if (s == "{v.wire}") return {t.name}::{v.name};')
o.append(f' return std::unexpected(ParseError{{"", "not a valid {t.name}: \'" + std::string(s) + "\'"}});')
o.append("}")
o.append("")
o.append(f"void to_json(nlohmann::json& j, const {t.name}& v) {{ j = to_string(v); }}")
o.append("")
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path) {{")
o.append(' if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"});')
o.append(f" auto r = parse_{t.name}(j.get_ref<const std::string&>());")
o.append(" if (!r) return std::unexpected(ParseError{std::string(path), r.error().message});")
o.append(" return *r;")
o.append("}")
o.append("")
elif t.kind == "int_enum":
o.append(f"std::string_view to_string({t.name} v) noexcept {{")
o.append(" switch (v) {")
for v in t.values:
o.append(f' case {t.name}::{v.name}: return "{v.name}";')
o.append(" }")
o.append(' return "";')
o.append("}")
o.append("")
o.append(f"std::optional<{t.name}> {t.name.lower()}_from_int(std::int32_t v) noexcept {{")
o.append(" switch (v) {")
for v in t.values:
o.append(f" case {v.wire}: return {t.name}::{v.name};")
o.append(" default: return std::nullopt;")
o.append(" }")
o.append("}")
o.append("")
o.append(f"void to_json(nlohmann::json& j, const {t.name}& v) {{ j = static_cast<std::int32_t>(v); }}")
o.append("")
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path) {{")
o.append(' if (!j.is_number_integer()) return std::unexpected(ParseError{std::string(path), "expected an integer"});')
o.append(f" auto v = {t.name.lower()}_from_int(j.get<std::int32_t>());")
o.append(' if (!v) return std::unexpected(ParseError{std::string(path), "not a contract error code"});')
o.append(" return *v;")
o.append("}")
o.append("")
# map aliases: a parse specialisation only. The alias is a std::map, which nlohmann
# already serialises, so an emitted to_json here would be an ambiguous overload.
for t in topo_sorted(c.types):
if t.kind != "map_alias":
continue
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path) {{")
o += emit_value_parse(t.alias, "j", "out", "path", " ")
o.append(" return out;")
o.append("}")
o.append("")
# struct to_json / parse
for t in topo_sorted(c.types):
if t.kind != "struct":
continue
# A no-parameter method's struct has nothing to read, so the parameter goes
# unnamed: the project builds with -Wall -Wextra -Werror.
vname = "v" if t.fields else "/*v*/"
o.append(f"void to_json(nlohmann::json& j, const {t.name}& {vname}) {{")
o.append(" j = nlohmann::json::object();")
for f in t.fields:
m = ident(f.name)
if f.optional:
o.append(f' if (v.{m}.has_value()) j["{f.name}"] = *v.{m};')
if f.required:
o.append(f' else j["{f.name}"] = nullptr;')
else:
o.append(f' j["{f.name}"] = v.{m};')
o.append("}")
o.append("")
pname = "path" if t.fields else "/*path*/"
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view {pname}) {{")
if t.fields:
o.append(' if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"});')
else:
o.append(' if (!j.is_object()) return std::unexpected(ParseError{"", "expected an object"});')
o.append(f" {t.name} out;")
for f in t.fields:
o += emit_field_parse(f, indent=" ")
o.append(" return out;")
o.append("}")
o.append("")
o += emit_tables(c)
o += emit_dispatch(c)
o += ["} // namespace velox::proto", ""]
return "\n".join(o)
def emit_value_parse(ref: TypeRef, src: str, dst: str, path: str, indent: str) -> list[str]:
"""Statements that parse json expression `src` into a fresh variable named `dst`."""
i = indent
o: list[str] = []
if ref.kind == "named":
o.append(f"{i}auto {dst}_r = parse<{ref.name}>({src}, {path});")
o.append(f"{i}if (!{dst}_r) return std::unexpected({dst}_r.error());")
o.append(f"{i}auto {dst} = std::move(*{dst}_r);")
elif ref.kind == "string":
o.append(f"{i}if (!{src}.is_string()) return std::unexpected(ParseError{{std::string({path}), \"expected a string\"}});")
o.append(f"{i}auto {dst} = {src}.get<std::string>();")
elif ref.kind == "integer":
o.append(f"{i}if (!{src}.is_number_integer()) return std::unexpected(ParseError{{std::string({path}), \"expected an integer\"}});")
o.append(f"{i}auto {dst} = {src}.get<std::int64_t>();")
elif ref.kind == "number":
o.append(f"{i}if (!{src}.is_number()) return std::unexpected(ParseError{{std::string({path}), \"expected a number\"}});")
o.append(f"{i}auto {dst} = {src}.get<double>();")
elif ref.kind == "boolean":
o.append(f"{i}if (!{src}.is_boolean()) return std::unexpected(ParseError{{std::string({path}), \"expected a boolean\"}});")
o.append(f"{i}auto {dst} = {src}.get<bool>();")
elif ref.kind == "json":
o.append(f"{i}auto {dst} = {src};")
elif ref.kind == "array":
et = cpp_type(ref.inner)
o.append(f"{i}if (!{src}.is_array()) return std::unexpected(ParseError{{std::string({path}), \"expected an array\"}});")
o.append(f"{i}std::vector<{et}> {dst};")
o.append(f"{i}{dst}.reserve({src}.size());")
o.append(f"{i}for (std::size_t idx = 0; idx < {src}.size(); ++idx) {{")
o.append(f'{i} const std::string ip = join({path}, std::to_string(idx));')
o += emit_value_parse(ref.inner, f"{src}[idx]", f"{dst}_e", "ip", i + " ")
o.append(f"{i} {dst}.push_back(std::move({dst}_e));")
o.append(f"{i}}}")
elif ref.kind == "map":
et = cpp_type(ref.inner)
o.append(f"{i}if (!{src}.is_object()) return std::unexpected(ParseError{{std::string({path}), \"expected an object\"}});")
o.append(f"{i}std::map<std::string, {et}> {dst};")
o.append(f"{i}for (const auto& [mk, mv] : {src}.items()) {{")
o.append(f'{i} const std::string mp = join({path}, mk);')
o += emit_value_parse(ref.inner, "mv", f"{dst}_e", "mp", i + " ")
o.append(f"{i} {dst}.emplace(mk, std::move({dst}_e));")
o.append(f"{i}}}")
else:
raise AssertionError(ref.kind)
o += cpp_constraints(ref, dst, path, indent)
return o
def emit_field_parse(f: Field, indent: str) -> list[str]:
i = indent
m = ident(f.name)
o = [f'{i}{{',
f'{i} const std::string fp = join(path, "{f.name}");',
f'{i} const auto it = j.find("{f.name}");']
if f.optional:
# Absent and null mean the same thing: the field is not set. A client that omits
# a nullable field and one that sends null are treated identically on purpose.
o.append(f"{i} if (it != j.end() && !it->is_null()) {{")
o += emit_value_parse(f.type, "(*it)", "val", "fp", i + " ")
o.append(f"{i} out.{m} = std::move(val);")
o.append(f"{i} }}")
else:
o.append(f"{i} if (it == j.end() || it->is_null())")
o.append(f'{i} return std::unexpected(ParseError{{fp, "required field is missing"}});')
o += emit_value_parse(f.type, "(*it)", "val", "fp", i + " ")
o.append(f"{i} out.{m} = std::move(val);")
o.append(f"{i}}}")
return o
def emit_tables(c: Contract) -> list[str]:
o = ["std::string_view to_string(Method m) noexcept {", " switch (m) {"]
for m in c.methods:
o.append(f' case Method::{method_ident(m.name)}: return "{m.name}";')
o += [" }", ' return "";', "}", ""]
o += ["std::optional<Method> method_from_string(std::string_view s) noexcept {"]
for m in c.methods:
o.append(f' if (s == "{m.name}") return Method::{method_ident(m.name)};')
o += [" return std::nullopt;", "}", ""]
o += ["bool is_privileged(Method m) noexcept {", " switch (m) {"]
for m in c.methods:
o.append(f" case Method::{method_ident(m.name)}: return {str(m.privileged).lower()};")
o += [" }", " return true; // unknown means refuse", "}", ""]
o += ["bool is_allowed_on(Method m, Transport t) noexcept {", " switch (m) {"]
for m in c.methods:
uds = "true" if "uds" in m.transports else "false"
ws = "true" if "ws" in m.transports else "false"
o.append(f" case Method::{method_ident(m.name)}: return t == Transport::Uds ? {uds} : {ws};")
o += [" }", " return false;", "}", ""]
o += ["std::int32_t deadline_ms(Method m) noexcept {", " switch (m) {"]
for m in c.methods:
o.append(f" case Method::{method_ident(m.name)}: return {m.deadline_ms};")
o += [" }", " return 5000;", "}", ""]
o += ["std::string_view to_string(Event e) noexcept {", " switch (e) {"]
for e in c.events:
o.append(f' case Event::{pascal(e.name[len("event."):])}: return "{e.name}";')
o += [" }", ' return "";', "}", ""]
o += ["std::optional<Event> event_from_string(std::string_view s) noexcept {"]
for e in c.events:
o.append(f' if (s == "{e.name}") return Event::{pascal(e.name[len("event."):])};')
o += [" return std::nullopt;", "}", ""]
return o
def emit_dispatch(c: Contract) -> list[str]:
o = [
"nlohmann::json make_error(const nlohmann::json& id, ErrorCode code, std::string_view message,",
" nlohmann::json data) {",
" nlohmann::json err = {{\"code\", static_cast<std::int32_t>(code)}, {\"message\", std::string(message)}};",
" if (!data.is_null()) err[\"data\"] = std::move(data);",
" return {{\"jsonrpc\", \"2.0\"}, {\"id\", id}, {\"error\", std::move(err)}};",
"}",
"",
"nlohmann::json make_result(const nlohmann::json& id, nlohmann::json result) {",
" return {{\"jsonrpc\", \"2.0\"}, {\"id\", id}, {\"result\", std::move(result)}};",
"}",
"",
"nlohmann::json make_notification(Event e, nlohmann::json params) {",
" return {{\"jsonrpc\", \"2.0\"}, {\"method\", std::string(to_string(e))}, {\"params\", std::move(params)}};",
"}",
"",
"nlohmann::json dispatch(Dispatcher& handler, Transport transport, const nlohmann::json& request) {",
" const nlohmann::json id = request.contains(\"id\") ? request.at(\"id\") : nlohmann::json(nullptr);",
"",
" if (!request.is_object() || request.value(\"jsonrpc\", \"\") != \"2.0\" || !request.contains(\"method\"))",
" return make_error(id, ErrorCode::InvalidRequest, \"not a JSON-RPC 2.0 request\");",
" if (!request.at(\"method\").is_string())",
" return make_error(id, ErrorCode::InvalidRequest, \"method must be a string\");",
"",
" const auto method = method_from_string(request.at(\"method\").get_ref<const std::string&>());",
" if (!method)",
" return make_error(id, ErrorCode::MethodNotFound, \"no such method\");",
" if (!is_allowed_on(*method, transport))",
" return make_error(id, ErrorCode::TransportForbidden,",
" \"method is not permitted on this transport\");",
"",
" const nlohmann::json params =",
" request.contains(\"params\") ? request.at(\"params\") : nlohmann::json::object();",
"",
" switch (*method) {",
]
for m in c.methods:
pt, rt = cpp_type(m.params), cpp_type(m.result)
o += [
f" case Method::{method_ident(m.name)}: {{",
f' auto p = parse<{pt}>(params, "params");',
" if (!p)",
" return make_error(id, ErrorCode::InvalidParams, p.error().message,",
' nlohmann::json{{"path", p.error().path}});',
f" auto r = handler.{handler_name(m.name)}(*p);",
" if (!r)",
" return make_error(id, ErrorCode::InternalError, r.error().message,",
' nlohmann::json{{"path", r.error().path}});',
" nlohmann::json out = *r;",
" return make_result(id, std::move(out));",
" }",
]
o += [" }", "",
" return make_error(id, ErrorCode::MethodNotFound, \"no such method\");",
"}", ""]
return o
def main() -> int:
c = load()
OUT_DIR.mkdir(parents=True, exist_ok=True)
(OUT_DIR / "velox_proto.hpp").write_text(emit_header(c))
(OUT_DIR / "velox_proto.cpp").write_text(emit_source(c))
print(f"gen_cpp: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events -> {OUT_DIR}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Emit tests/conformance/cpp/fixture_dispatcher.hpp.
velox::proto::Dispatcher has one pure virtual per method, on purpose: a daemon that
forgets to implement a method does not compile. That is exactly what a conformance runner
needs, and also what makes one tedious to hand-write — so it is generated.
The dispatcher answers each method from that method's golden fixture, which lets the C++
side exercise the real dispatch path: envelope handling, the transport check, parameter
parsing, and result serialisation.
Run: python3 contracts/codegen/gen_cpp_conformance.py
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from gen_cpp import BANNER, cpp_type, handler_name # noqa: E402
from schema_ir import load # noqa: E402
OUT = Path(__file__).resolve().parent.parent.parent / "tests" / "conformance" / "cpp" / "fixture_dispatcher.hpp"
def main() -> int:
c = load()
o = [BANNER.format(version=c.version), "#pragma once", "",
'#include "velox_proto.hpp"', "",
"#include <functional>", "#include <string>", "", "namespace velox::conformance {", "",
"/// Answers every method from its golden fixture, so the generated dispatch path",
"/// itself is under test: envelope, transport check, param parse, result serialise.",
"class FixtureDispatcher final : public proto::Dispatcher {",
"public:",
" /// `results` maps a method name to that method's golden result JSON.",
" explicit FixtureDispatcher(std::function<const nlohmann::json*(const std::string&)> results)",
" : results_(std::move(results)) {}",
""]
for m in c.methods:
# Every method's params and result is a named struct, and this class lives in
# velox::conformance, so the names need qualifying.
pt, rt = "proto::" + cpp_type(m.params), "proto::" + cpp_type(m.result)
o += [
f" proto::Result<{rt}> {handler_name(m.name)}(const {pt}& params) override {{",
" (void)params;",
f' return golden<{rt}>("{m.name}");',
" }",
"",
]
o += [
"private:",
" template <class T>",
" proto::Result<T> golden(const std::string& method) {",
" const nlohmann::json* value = results_(method);",
" if (value == nullptr)",
' return std::unexpected(proto::ParseError{method, "no fixture for this method"});',
" return proto::parse<T>(*value, method);",
" }",
"",
" std::function<const nlohmann::json*(const std::string&)> results_;",
"};",
"",
"} // namespace velox::conformance",
"",
]
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text("\n".join(o))
print(f"gen_cpp_conformance: {len(c.methods)} handlers -> {OUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Emit contracts/openrpc.json from contracts/schema/.
This is the document humans read. It is generated, not written, so it cannot drift from
the schemas the code is generated from — the failure mode where the docs say one thing and
the wire does another is designed out rather than policed.
JSON-RPC named parameters are modelled as OpenRPC `by-name` params: each property of a
method's params object becomes one entry, which is what a reader expects to see.
Server-to-client notifications are not expressible in OpenRPC 1.2, so they are emitted
under a top-level `x-events` key alongside their payload schemas.
Run: python3 contracts/codegen/gen_openrpc.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from schema_ir import ID_PREFIX, SCHEMA_ROOT, Loader # noqa: E402
ROOT = SCHEMA_ROOT.parent
OUT = ROOT / "openrpc.json"
STRIP = {"$schema", "$id", "title"}
def rewrite(node: object) -> object:
"""Point every $ref at #/components/schemas/<Name> and drop per-file keywords."""
if isinstance(node, list):
return [rewrite(n) for n in node]
if not isinstance(node, dict):
return node
out: dict[str, object] = {}
for k, v in node.items():
if k == "$ref" and isinstance(v, str):
if v.startswith("#/$defs/"):
# envelope.schema.json's internal refs land under the x-envelope key.
out["$ref"] = "#/x-envelope/" + v[len("#/$defs/"):]
continue
if not v.startswith(ID_PREFIX + "types/"):
raise SystemExit(f"openrpc: unexpected $ref target {v}")
name = v[len(ID_PREFIX + "types/"):].removesuffix(".schema.json")
out["$ref"] = f"#/components/schemas/{name}"
continue
if k in STRIP:
continue
out[k] = rewrite(v)
return out
def main() -> int:
loader = Loader()
version = (ROOT / "VERSION").read_text().strip()
schemas: dict[str, object] = {}
for sid, doc in sorted(loader.by_id.items()):
if "/types/" not in sid:
continue
name = doc["title"]
body = rewrite({k: v for k, v in doc.items() if k not in STRIP})
assert isinstance(body, dict)
body["title"] = name
schemas[name] = body
methods = []
for sid, doc in sorted(loader.by_id.items()):
if "/methods/" not in sid:
continue
params_schema = doc["properties"]["params"]
# Expand a params object into by-name entries. A $ref'd params object is resolved
# first so the reader sees the fields, not just a type name.
resolved = params_schema
if "$ref" in resolved:
resolved = loader.by_id[resolved["$ref"]]
params = []
required = set(resolved.get("required", []))
for prop, sub in resolved.get("properties", {}).items():
entry: dict[str, object] = {"name": prop, "schema": rewrite(sub)}
if prop in required:
entry["required"] = True
if isinstance(sub, dict) and sub.get("description"):
entry["description"] = sub["description"]
params.append(entry)
method: dict[str, object] = {
"name": doc["title"],
"summary": doc.get("description", "").split(".")[0] + ".",
"description": doc.get("description", ""),
"paramStructure": "by-name",
"params": params,
"result": {"name": f"{doc['title']}Result", "schema": rewrite(doc["properties"]["result"])},
"x-privileged": doc.get("x-privileged", False),
"x-transports": doc.get("x-transports", []),
"x-deadlineMs": doc.get("x-deadlineMs"),
}
if doc.get("x-errors"):
code_doc = {e["value"]: e["doc"] for e in
loader.by_id[ID_PREFIX + "types/ErrorCode.schema.json"]["x-enum"]}
method["errors"] = [{"code": c, "message": code_doc.get(c, "")} for c in doc["x-errors"]]
if doc.get("x-wsRestrictions"):
method["x-wsRestrictions"] = doc["x-wsRestrictions"]
methods.append(method)
events = []
for sid, doc in sorted(loader.by_id.items()):
if "/events/" not in sid:
continue
events.append({
"name": doc["title"],
"description": doc.get("description", ""),
"params": rewrite(doc["properties"]["params"]),
"x-maxRateHz": doc.get("x-maxRateHz"),
})
envelope = loader.by_id[ID_PREFIX + "envelope.schema.json"]
out = {
"openrpc": "1.2.6",
"info": {
"title": "Velox Download Manager",
"version": version,
"description": (
"The wire contract between veloxd and every client: the Qt GUI, the CLI, "
"the native-messaging host and the Firefox extension. One JSON-RPC 2.0 "
"payload set over four framings; only the framing differs.\n\n"
"GENERATED from contracts/schema/ by contracts/codegen/gen_openrpc.py. "
"Do not edit by hand."
),
"license": {"name": "See repository LICENSE"},
},
"servers": [
{"name": "unix-socket", "url": "unix:$XDG_RUNTIME_DIR/velox/velox.sock",
"description": "NDJSON. GUI, CLI and nmhost. Peer credentials checked via SO_PEERCRED; same UID only, no token."},
{"name": "loopback-ws", "url": "ws://127.0.0.1:52000",
"description": "One JSON message per text frame. Extension fallback. Bound to 127.0.0.1 only, Origin-checked, token-authenticated, rate-limited. Port is the first free one in 52000-52016."},
],
"methods": methods,
"components": {"schemas": schemas},
"x-events": events,
"x-envelope": rewrite(envelope.get("$defs", {})),
"x-transports": {
"uds": "Unix domain socket, newline-delimited JSON.",
"ws": "Loopback WebSocket, one JSON message per text frame. Privileged methods are refused here with -32003.",
},
}
OUT.write_text(json.dumps(out, indent=2) + "\n")
print(f"gen_openrpc: {len(methods)} methods, {len(events)} events, {len(schemas)} schemas -> {OUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+619
View File
@@ -0,0 +1,619 @@
#!/usr/bin/env python3
"""Emit extension/src/shared/protocol/ from contracts/schema/.
What the EXT and GUI-adjacent lanes get:
* `types.ts` — every contract type as a TS interface or string-literal union.
* `methods.ts` — the `MethodMap`, a typed `call<M>()` signature, and per-method
metadata (privileged, transports, deadlineMs). capture.offer's
750 ms budget is a generated constant, not a number typed twice.
* `events.ts` — event payload types and a discriminated union of notifications.
* `validate.ts` — runtime validators for everything crossing the wire.
* `index.ts` — the public surface.
The validators exist because **the extension is not allowed to trust the daemon and the
daemon is not allowed to trust the extension.** A `ws://127.0.0.1` socket is reachable by
any local process, so a TypeScript type alone proves nothing at runtime.
Run: python3 contracts/codegen/gen_ts.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from schema_ir import Contract, Field, TypeDef, TypeRef, load, pascal, topo_sorted # noqa: E402
OUT_DIR = Path(__file__).resolve().parent.parent.parent / "extension" / "src" / "shared" / "protocol"
BANNER = """// ---------------------------------------------------------------------------
// GENERATED FILE — DO NOT EDIT.
//
// Source: contracts/schema/**
// Generator: contracts/codegen/gen_ts.py
// Contract: v{version}
//
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
// python3 contracts/codegen/gen_ts.py
// Only lane PROTO commits to contracts/.
// ---------------------------------------------------------------------------
"""
IDENT_OK = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$")
def prop_key(name: str) -> str:
"""Property names such as `general.launchOnLogin` must be quoted."""
if name and name[0].isalpha() and all(ch in IDENT_OK for ch in name):
return name
return json.dumps(name)
def ts_type(ref: TypeRef) -> str:
if ref.kind == "named":
return ref.name or "never"
if ref.kind == "string":
return "string"
if ref.kind in ("integer", "number"):
return "number"
if ref.kind == "boolean":
return "boolean"
if ref.kind == "json":
return "unknown"
if ref.kind == "array":
inner = ts_type(ref.inner)
return f"Array<{inner}>" if not inner.isidentifier() else f"{inner}[]"
if ref.kind == "map":
return f"Record<string, {ts_type(ref.inner)}>"
raise AssertionError(ref.kind)
def doc_block(text: str, indent: str = "") -> list[str]:
if not text:
return []
words, lines, cur = text.split(), [], ""
for w in words:
if len(cur) + len(w) + 1 > 88:
lines.append(cur)
cur = w
else:
cur = f"{cur} {w}".strip()
if cur:
lines.append(cur)
if len(lines) == 1:
return [f"{indent}/** {lines[0]} */"]
return [f"{indent}/**"] + [f"{indent} * {ln}" for ln in lines] + [f"{indent} */"]
def event_ident(name: str) -> str:
return pascal(name[len("event."):]) + "Event"
def method_key(name: str) -> str:
return json.dumps(name)
# ------------------------------------------------------------------- types.ts
def emit_types(c: Contract) -> str:
o = [BANNER.format(version=c.version), ""]
o.append(f'export const PROTOCOL_VERSION = {json.dumps(c.version)};')
o.append("")
for t in topo_sorted(c.types):
if t.kind == "string_enum":
o += doc_block(t.doc)
union = " | ".join(json.dumps(v.wire) for v in t.values)
o.append(f"export type {t.name} = {union};")
o.append(f"export const {upper_snake(t.name)}_VALUES = [")
for v in t.values:
o.append(f" {json.dumps(v.wire)},")
o.append(f"] as const satisfies readonly {t.name}[];")
o.append("")
elif t.kind == "int_enum":
o += doc_block(t.doc)
o.append(f"export const {t.name} = {{")
for v in t.values:
if v.doc:
o += doc_block(v.doc, " ")
o.append(f" {v.name}: {v.wire},")
o.append("} as const;")
o.append(f"export type {t.name} = (typeof {t.name})[keyof typeof {t.name}];")
o.append("")
elif t.kind == "map_alias":
o += doc_block(t.doc)
o.append(f"export type {t.name} = {ts_type(t.alias)};")
o.append("")
elif t.kind == "struct":
o += doc_block(t.doc)
if not t.fields:
o.append(f"/** No parameters. */")
o.append(f"export type {t.name} = Record<string, never>;")
o.append("")
continue
o.append(f"export interface {t.name} {{")
for f in t.fields:
o += doc_block(f.doc, " ")
opt = "?" if not f.required else ""
null = " | null" if f.nullable else ""
o.append(f" {prop_key(f.name)}{opt}: {ts_type(f.type)}{null};")
o.append("}")
o.append("")
o += [
"/** JSON-RPC error as it appears on the wire. */",
"export interface RpcError {",
" code: ErrorCode;",
" message: string;",
" data?: Record<string, unknown> | null;",
"}",
"",
"/** A response is one or the other, never both — narrow on `error`. */",
"export type RpcResponse<T> =",
" | { jsonrpc: '2.0'; id: number | string; result: T; error?: undefined }",
" | { jsonrpc: '2.0'; id: number | string; result?: undefined; error: RpcError };",
"",
]
return "\n".join(o)
def upper_snake(name: str) -> str:
out = []
for i, ch in enumerate(name):
if ch.isupper() and i and not name[i - 1].isupper():
out.append("_")
out.append(ch.upper())
return "".join(out)
# ----------------------------------------------------------------- methods.ts
def emit_methods(c: Contract) -> str:
imports = sorted({r.name for m in c.methods for r in (m.params, m.result) if r.kind == "named"})
o = [BANNER.format(version=c.version), ""]
o.append("import type {")
for name in imports:
o.append(f" {name},")
o.append("} from './types.js';")
o.append("")
o += [
"/** Params and result for every method, keyed by its wire name. */",
"export interface MethodMap {",
]
for m in c.methods:
o += doc_block(m.doc, " ")
o.append(f" {method_key(m.name)}: {{ params: {ts_type(m.params)}; result: {ts_type(m.result)} }};")
o += ["}", "",
"export type MethodName = keyof MethodMap;",
"export type Params<M extends MethodName> = MethodMap[M]['params'];",
"export type Result<M extends MethodName> = MethodMap[M]['result'];",
"",
"export type Transport = 'uds' | 'ws';",
"",
"export interface MethodMeta {",
" /** Refused over the WebSocket transport with -32003. */",
" readonly privileged: boolean;",
" readonly transports: readonly Transport[];",
" /** How long a client waits before giving up on this call. */",
" readonly deadlineMs: number;",
" /** Error codes this method is documented to return. */",
" readonly errors: readonly number[];",
"}",
"",
"export const METHODS: { readonly [M in MethodName]: MethodMeta } = {"]
for m in c.methods:
transports = ", ".join(f"'{t}'" for t in m.transports)
errors = ", ".join(str(e) for e in m.errors)
o.append(f" {method_key(m.name)}: {{ privileged: {str(m.privileged).lower()}, "
f"transports: [{transports}], deadlineMs: {m.deadline_ms}, errors: [{errors}] }},")
o += ["} as const;", "",
"export const METHOD_NAMES = Object.keys(METHODS) as MethodName[];",
"",
"export function isMethodName(v: unknown): v is MethodName {",
" return typeof v === 'string' && Object.prototype.hasOwnProperty.call(METHODS, v);",
"}",
"",
"/** Methods this transport may call. The extension checks before sending so a",
" * privileged call fails in one place rather than as a puzzling -32003. */",
"export function isAllowedOn(method: MethodName, transport: Transport): boolean {",
" return (METHODS[method].transports as readonly string[]).includes(transport);",
"}",
"",
"/**",
" * The typed client surface. Every transport implements this; the generated",
" * signature is what stops a caller passing download.add's params to download.get.",
" */",
"export interface VeloxClient {",
" call<M extends MethodName>(method: M, params: Params<M>): Promise<Result<M>>;",
"}",
""]
return "\n".join(o)
# ------------------------------------------------------------------ events.ts
def emit_events(c: Contract) -> str:
imports = sorted({e.params.name for e in c.events if e.params.kind == "named"})
o = [BANNER.format(version=c.version), ""]
o.append("import type {")
for name in imports:
o.append(f" {name},")
o.append("} from './types.js';")
o.append("")
o.append("/** Payload for each server-to-client notification, keyed by its wire name. */")
o.append("export interface EventMap {")
for e in c.events:
o += doc_block(e.doc, " ")
o.append(f" {method_key(e.name)}: {ts_type(e.params)};")
o += ["}", "",
"export type EventName = keyof EventMap;",
"export type EventPayload<E extends EventName> = EventMap[E];",
"",
"/**",
" * Discriminated on `method`: narrowing an incoming notification gives the",
" * correctly typed params with no cast at the call site.",
" */",
"export type ServerNotification = {",
" [E in EventName]: { jsonrpc: '2.0'; method: E; params: EventMap[E] };",
"}[EventName];",
"",
"export interface EventMeta {",
" /** Upper bound on emission rate, where the contract sets one. */",
" readonly maxRateHz: number | null;",
"}",
"",
"export const EVENTS: { readonly [E in EventName]: EventMeta } = {"]
for e in c.events:
rate = "null" if e.max_rate_hz is None else str(e.max_rate_hz)
o.append(f" {method_key(e.name)}: {{ maxRateHz: {rate} }},")
o += ["} as const;", "",
"export const EVENT_NAMES = Object.keys(EVENTS) as EventName[];",
"",
"export function isEventName(v: unknown): v is EventName {",
" return typeof v === 'string' && Object.prototype.hasOwnProperty.call(EVENTS, v);",
"}",
""]
return "\n".join(o)
# ---------------------------------------------------------------- validate.ts
PRELUDE = """
/**
* Runtime validation for everything that crosses the wire.
*
* The daemon does not trust the extension and the extension does not trust the daemon:
* `ws://127.0.0.1` is reachable by any local process, so a TypeScript type proves nothing
* at runtime. Every inbound payload goes through one of these before it is used.
*
* Validators mirror the C++ side exactly, including the rule that an absent field and an
* explicit null mean the same thing.
*/
export type Validated<T> =
| { ok: true; value: T }
| { ok: false; path: string; message: string };
export type Validator<T> = (v: unknown, path: string) => Validated<T>;
function fail(path: string, message: string): Validated<never> {
return { ok: false, path, message };
}
function join(path: string, key: string): string {
return path ? `${path}/${key}` : `/${key}`;
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
export const vString: Validator<string> = (v, p) =>
typeof v === 'string' ? { ok: true, value: v } : fail(p, 'expected a string');
export const vNumber: Validator<number> = (v, p) =>
typeof v === 'number' && Number.isFinite(v) ? { ok: true, value: v } : fail(p, 'expected a number');
export const vInteger: Validator<number> = (v, p) =>
typeof v === 'number' && Number.isInteger(v) ? { ok: true, value: v } : fail(p, 'expected an integer');
export const vBoolean: Validator<boolean> = (v, p) =>
typeof v === 'boolean' ? { ok: true, value: v } : fail(p, 'expected a boolean');
export const vUnknown: Validator<unknown> = (v) => ({ ok: true, value: v });
function vArray<T>(inner: Validator<T>): Validator<T[]> {
return (v, p) => {
if (!Array.isArray(v)) return fail(p, 'expected an array');
const out: T[] = [];
for (let i = 0; i < v.length; i += 1) {
const r = inner(v[i], join(p, String(i)));
if (!r.ok) return r;
out.push(r.value);
}
return { ok: true, value: out };
};
}
function vRecord<T>(inner: Validator<T>): Validator<Record<string, T>> {
return (v, p) => {
if (!isPlainObject(v)) return fail(p, 'expected an object');
const out: Record<string, T> = {};
for (const [k, raw] of Object.entries(v)) {
const r = inner(raw, join(p, k));
if (!r.ok) return r;
out[k] = r.value;
}
return { ok: true, value: out };
};
}
/**
* Range, length and pattern checks. The wire is untrusted, so a `maximum` in the schema
* has to be a check at runtime — a TypeScript type cannot enforce one.
*/
interface Limits {
readonly minimum?: number;
readonly maximum?: number;
readonly minLength?: number;
readonly maxLength?: number;
readonly pattern?: RegExp;
readonly minItems?: number;
readonly maxItems?: number;
}
function vLimited<T>(inner: Validator<T>, limits: Limits): Validator<T> {
return (v, p) => {
const r = inner(v, p);
if (!r.ok) return r;
const value = r.value;
if (typeof value === 'number') {
if (limits.minimum !== undefined && value < limits.minimum)
return fail(p, `value is below the minimum of ${limits.minimum}`);
if (limits.maximum !== undefined && value > limits.maximum)
return fail(p, `value is above the maximum of ${limits.maximum}`);
} else if (typeof value === 'string') {
if (limits.minLength !== undefined && value.length < limits.minLength)
return fail(p, `value is shorter than ${limits.minLength} characters`);
if (limits.maxLength !== undefined && value.length > limits.maxLength)
return fail(p, `value is longer than ${limits.maxLength} characters`);
if (limits.pattern !== undefined && !limits.pattern.test(value))
return fail(p, 'value does not match the required pattern');
} else if (Array.isArray(value)) {
if (limits.minItems !== undefined && value.length < limits.minItems)
return fail(p, `fewer than ${limits.minItems} items`);
if (limits.maxItems !== undefined && value.length > limits.maxItems)
return fail(p, `more than ${limits.maxItems} items`);
}
return r;
};
}
function vEnum<T extends string>(values: readonly T[], name: string): Validator<T> {
return (v, p) =>
typeof v === 'string' && (values as readonly string[]).includes(v)
? { ok: true, value: v as T }
: fail(p, `not a valid ${name}`);
}
function vIntEnum<T extends number>(values: readonly T[], name: string): Validator<T> {
return (v, p) =>
typeof v === 'number' && (values as readonly number[]).includes(v)
? { ok: true, value: v as T }
: fail(p, `not a valid ${name}`);
}
/** Required: must be present and non-null. */
function req<T>(
obj: Record<string, unknown>,
key: string,
path: string,
inner: Validator<T>,
out: Record<string, unknown>,
): Validated<null> {
const raw = obj[key];
if (raw === undefined || raw === null) return fail(join(path, key), 'required field is missing');
const r = inner(raw, join(path, key));
if (!r.ok) return r;
out[key] = r.value;
return { ok: true, value: null };
}
/** Optional: absent and null are the same thing, exactly as on the C++ side. */
function opt<T>(
obj: Record<string, unknown>,
key: string,
path: string,
inner: Validator<T>,
out: Record<string, unknown>,
): Validated<null> {
const raw = obj[key];
if (raw === undefined || raw === null) return { ok: true, value: null };
const r = inner(raw, join(path, key));
if (!r.ok) return r;
out[key] = r.value;
return { ok: true, value: null };
}
"""
def ts_limits(ref: TypeRef) -> str:
"""The Limits object literal for a TypeRef, or "" when it is unconstrained."""
lim = ref.limits
parts = []
for key in ("minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"):
if key in lim:
parts.append(f"{key}: {lim[key]}")
if "pattern" in lim:
parts.append("pattern: " + js_regex(str(lim["pattern"])))
return "{ " + ", ".join(parts) + " }" if parts else ""
def js_regex(pattern: str) -> str:
return "/" + pattern.replace("/", "\\/") + "/"
def validator_expr(ref: TypeRef) -> str:
base = _validator_base(ref)
limits = ts_limits(ref)
return f"vLimited({base}, {limits})" if limits else base
def _validator_base(ref: TypeRef) -> str:
if ref.kind == "named":
return f"validate{ref.name}"
if ref.kind == "string":
return "vString"
if ref.kind == "integer":
return "vInteger"
if ref.kind == "number":
return "vNumber"
if ref.kind == "boolean":
return "vBoolean"
if ref.kind == "json":
return "vUnknown"
if ref.kind == "array":
return f"vArray({validator_expr(ref.inner)})"
if ref.kind == "map":
return f"vRecord({validator_expr(ref.inner)})"
raise AssertionError(ref.kind)
def emit_validate(c: Contract) -> str:
# An int enum is exported from types.ts as a const *and* a type under one name, so a
# value import already brings the type with it. Importing it twice is a TS2300.
value_imported = {t.name for t in c.types if t.kind == "int_enum"}
type_names = [t.name for t in c.types if t.name not in value_imported]
o = [BANNER.format(version=c.version), ""]
o.append("import type {")
for name in sorted(type_names):
o.append(f" {name},")
o.append("} from './types.js';")
o.append("import {")
for t in sorted(c.types, key=lambda t: t.name):
if t.kind == "string_enum":
o.append(f" {upper_snake(t.name)}_VALUES,")
elif t.kind == "int_enum":
o.append(f" {t.name},")
o.append("} from './types.js';")
o.append("import { isEventName, type EventMap, type EventName } from './events.js';")
o.append("import { isMethodName, type MethodMap, type MethodName } from './methods.js';")
o.append(PRELUDE)
for t in c.types:
if t.kind == "string_enum":
o.append(f"export const validate{t.name}: Validator<{t.name}> = "
f"vEnum({upper_snake(t.name)}_VALUES, '{t.name}');")
o.append("")
elif t.kind == "int_enum":
o.append(f"const {upper_snake(t.name)}_VALUES = Object.values({t.name}) as {t.name}[];")
o.append(f"export const validate{t.name}: Validator<{t.name}> = "
f"vIntEnum({upper_snake(t.name)}_VALUES, '{t.name}');")
o.append("")
elif t.kind == "map_alias":
o.append(f"export const validate{t.name}: Validator<{t.name}> = "
f"{validator_expr(t.alias)};")
o.append("")
elif t.kind == "struct":
o += doc_block(f"Validate an untrusted value as {t.name}.")
o.append(f"export function validate{t.name}(v: unknown, path = ''): Validated<{t.name}> {{")
o.append(" if (!isPlainObject(v)) return fail(path, 'expected an object');")
if not t.fields:
o.append(f" return {{ ok: true, value: {{}} as {t.name} }};")
o.append("}")
o.append("")
continue
o.append(" const out: Record<string, unknown> = {};")
o.append(" let r: Validated<null>;")
for f in t.fields:
fn = "opt" if f.optional else "req"
o.append(f" r = {fn}(v, {json.dumps(f.name)}, path, {validator_expr(f.type)}, out);")
o.append(" if (!r.ok) return r;")
o.append(f" return {{ ok: true, value: out as unknown as {t.name} }};")
o.append("}")
o.append("")
# dispatch tables
o += ["// --- by-name entry points --------------------------------------------------",
"",
"const PARAMS_VALIDATORS: { [M in MethodName]: Validator<MethodMap[M]['params']> } = {"]
for m in c.methods:
o.append(f" {method_key(m.name)}: {validator_expr(m.params)},")
o += ["};", "",
"const RESULT_VALIDATORS: { [M in MethodName]: Validator<MethodMap[M]['result']> } = {"]
for m in c.methods:
o.append(f" {method_key(m.name)}: {validator_expr(m.result)},")
o += ["};", "",
"const EVENT_VALIDATORS: { [E in EventName]: Validator<EventMap[E]> } = {"]
for e in c.events:
o.append(f" {method_key(e.name)}: {validator_expr(e.params)},")
o += ["};", "",
"/** Validate params the daemon is about to receive for `method`. */",
"export function validateParams<M extends MethodName>(method: M, v: unknown): Validated<MethodMap[M]['params']> {",
" return PARAMS_VALIDATORS[method](v, 'params');",
"}",
"",
"/** Validate a result the client just received for `method`. */",
"export function validateResult<M extends MethodName>(method: M, v: unknown): Validated<MethodMap[M]['result']> {",
" return RESULT_VALIDATORS[method](v, 'result');",
"}",
"",
"/** Validate a notification payload. */",
"export function validateEventParams<E extends EventName>(event: E, v: unknown): Validated<EventMap[E]> {",
" return EVENT_VALIDATORS[event](v, 'params');",
"}",
"",
"/**",
" * Validate a whole inbound notification frame, including its method name.",
" * Anything unrecognised is rejected rather than passed on: an unknown method on a",
" * loopback socket is either a version skew or another local process probing us.",
" */",
"export function validateNotification(",
" frame: unknown,",
"): Validated<{ method: EventName; params: EventMap[EventName] }> {",
" if (!isPlainObject(frame)) return fail('', 'expected an object');",
" if (frame['jsonrpc'] !== '2.0') return fail('/jsonrpc', \"expected '2.0'\");",
" const method = frame['method'];",
" if (!isEventName(method)) return fail('/method', 'unknown event');",
" const params = validateEventParams(method, frame['params']);",
" if (!params.ok) return params;",
" return { ok: true, value: { method, params: params.value } };",
"}",
"",
"export { isEventName, isMethodName };",
""]
return "\n".join(o)
def emit_index(c: Contract) -> str:
return "\n".join([
BANNER.format(version=c.version),
"",
"export * from './types.js';",
"export * from './methods.js';",
"export * from './events.js';",
"export * from './validate.js';",
"",
])
def main() -> int:
c = load()
OUT_DIR.mkdir(parents=True, exist_ok=True)
(OUT_DIR / "types.ts").write_text(emit_types(c))
(OUT_DIR / "methods.ts").write_text(emit_methods(c))
(OUT_DIR / "events.ts").write_text(emit_events(c))
(OUT_DIR / "validate.ts").write_text(emit_validate(c))
(OUT_DIR / "index.ts").write_text(emit_index(c))
print(f"gen_ts: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events -> {OUT_DIR}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+454
View File
@@ -0,0 +1,454 @@
"""Load contracts/schema/ and lower it into a small IR the generators emit from.
There is deliberately only one of these. gen_cpp.py, gen_ts.py and gen_openrpc.py all
consume the same IR, so the C++ structs, the TypeScript types and the human-readable
API document cannot disagree about what the contract says.
The IR covers exactly the JSON Schema subset the contract is allowed to use. Anything
outside it raises SchemaError at generation time rather than producing subtly wrong code:
a contract that cannot be generated from is a contract bug, and it should stop the build.
Supported subset
----------------
type: object / string / integer / number / boolean / array, and ["X", "null"]
object with `properties` -> struct
object with `additionalProperties: <schema>` -> map<string, T>
string with `enum` -> enum
integer with `enum` + x-enum -> named integer enum
array with `items` -> vector<T>
$ref to a types/*.schema.json -> named type
oneOf: [{$ref}, {type: null}] -> nullable named type
{} -> opaque JSON
Not supported, on purpose: allOf, anyOf, general oneOf, patternProperties, tuple items,
recursive types. If the contract needs one of these, extend this file in the same PR.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
SCHEMA_ROOT = Path(__file__).resolve().parent.parent / "schema"
ID_PREFIX = "https://velox.dev/schema/"
class SchemaError(Exception):
"""A schema the generators refuse to guess about."""
# --------------------------------------------------------------------------- IR
# Value constraints the generators enforce at runtime. The wire is never trusted, so a
# `maximum` in the schema has to be a check in the generated code, not just documentation.
CONSTRAINT_KEYS = ("minimum", "maximum", "minLength", "maxLength", "pattern",
"minItems", "maxItems")
@dataclass(frozen=True)
class TypeRef:
"""A reference to a type from a field. `kind` drives every emitter's switch."""
kind: str # named | string | integer | number | boolean | array | map | json
name: str | None = None # kind == "named"
inner: "TypeRef | None" = None # kind in ("array", "map")
constraints: tuple[tuple[str, object], ...] = ()
@property
def limits(self) -> dict[str, object]:
return dict(self.constraints)
@dataclass
class Field:
name: str
type: TypeRef
required: bool
nullable: bool
doc: str = ""
@property
def optional(self) -> bool:
"""Whether the emitted field needs an optional/undefined-able representation."""
return (not self.required) or self.nullable
@dataclass
class EnumValue:
name: str # identifier form, e.g. RetryWait
wire: object # the value on the wire: "retry_wait" or -32010
doc: str = ""
@dataclass
class TypeDef:
name: str
kind: str # struct | string_enum | int_enum | map_alias
doc: str = ""
fields: list[Field] = field(default_factory=list)
values: list[EnumValue] = field(default_factory=list)
alias: TypeRef | None = None
source: str = "" # relative path, for the "do not edit" banner
@dataclass
class Method:
name: str # download.addBatch
doc: str
params: TypeRef
result: TypeRef
privileged: bool
transports: list[str]
deadline_ms: int
errors: list[int] = field(default_factory=list)
ws_restrictions: list[str] = field(default_factory=list)
@dataclass
class Event:
name: str # event.task.progress
doc: str
params: TypeRef
max_rate_hz: float | None = None
@dataclass
class Contract:
version: str
types: list[TypeDef]
methods: list[Method]
events: list[Event]
error_codes: list[EnumValue]
# ------------------------------------------------------------------ name helpers
def pascal(text: str) -> str:
"""download.addBatch -> DownloadAddBatch ; retry_wait -> RetryWait."""
parts = re.split(r"[.\-_ ]+", text)
out = []
for part in parts:
if not part:
continue
out.append(part[0].upper() + part[1:])
return "".join(out)
def enum_ident(value: str) -> str:
ident = pascal(value)
if not ident:
raise SchemaError(f"cannot derive an identifier from enum value {value!r}")
if ident[0].isdigit():
ident = "V" + ident
return ident
# ---------------------------------------------------------------------- loading
class Loader:
def __init__(self, root: Path = SCHEMA_ROOT):
self.root = root
self.by_id: dict[str, dict] = {}
self.source_of: dict[str, str] = {}
for path in sorted(root.rglob("*.schema.json")):
with path.open() as fh:
doc = json.load(fh)
sid = doc.get("$id")
if not sid:
raise SchemaError(f"{path} has no $id")
if sid in self.by_id:
raise SchemaError(f"duplicate $id {sid} in {path}")
self.by_id[sid] = doc
self.source_of[sid] = str(path.relative_to(root.parent))
self.types: dict[str, TypeDef] = {}
self._order: list[str] = []
# -- ref handling -------------------------------------------------------
def _resolve_ref(self, ref: str) -> dict:
if ref.startswith("#"):
raise SchemaError(f"local $ref {ref} is not supported outside envelope.schema.json")
if ref not in self.by_id:
raise SchemaError(f"unknown $ref {ref}")
return self.by_id[ref]
@staticmethod
def _split_nullable(node: dict) -> tuple[dict, bool]:
"""Normalise the two ways the contract spells 'or null'."""
if "oneOf" in node:
branches = node["oneOf"]
non_null = [b for b in branches if b.get("type") != "null"]
nulls = [b for b in branches if b.get("type") == "null"]
if len(branches) != 2 or len(non_null) != 1 or len(nulls) != 1:
raise SchemaError(
"oneOf is only supported as [<schema>, {type: null}]; got "
+ json.dumps(branches)[:200]
)
merged = dict(non_null[0])
for key in ("description",):
if key in node and key not in merged:
merged[key] = node[key]
return merged, True
t = node.get("type")
if isinstance(t, list):
non_null = [x for x in t if x != "null"]
if len(non_null) != 1:
raise SchemaError(f"union type {t} is only supported as [X, 'null']")
node = dict(node)
node["type"] = non_null[0]
# An enum listing null alongside its values means the same thing.
if "enum" in node:
node["enum"] = [v for v in node["enum"] if v is not None]
return node, True
if "enum" in node and None in node["enum"]:
node = dict(node)
node["enum"] = [v for v in node["enum"] if v is not None]
return node, True
return node, False
# -- lowering -----------------------------------------------------------
def type_ref(self, node: dict, hint: str) -> tuple[TypeRef, bool]:
"""Lower a schema node to a TypeRef. `hint` names any struct we must synthesise."""
node, nullable = self._split_nullable(node)
limits = tuple((k, node[k]) for k in CONSTRAINT_KEYS if k in node)
if "$ref" in node:
target = self._resolve_ref(node["$ref"])
return TypeRef("named", name=self.named_type(node["$ref"], target)), nullable
if not node or node.keys() <= {"description"}:
return TypeRef("json"), True
t = node.get("type")
if t == "string":
if "enum" in node:
return TypeRef("named", name=self._synth_string_enum(hint, node)), nullable
return TypeRef("string", constraints=limits), nullable
if t == "integer":
return TypeRef("integer", constraints=limits), nullable
if t == "number":
return TypeRef("number", constraints=limits), nullable
if t == "boolean":
return TypeRef("boolean", constraints=limits), nullable
if t == "array":
items = node.get("items")
if items is None:
raise SchemaError(f"array without items at {hint}")
inner, _ = self.type_ref(items, hint + "Item")
return TypeRef("array", inner=inner, constraints=limits), nullable
if t == "object":
if "properties" in node:
return TypeRef("named", name=self._synth_struct(hint, node)), nullable
ap = node.get("additionalProperties")
if isinstance(ap, dict):
inner, _ = self.type_ref(ap, hint + "Value")
return TypeRef("map", inner=inner), nullable
return TypeRef("json"), nullable
if "const" in node:
return TypeRef("string", constraints=limits), nullable
raise SchemaError(f"unsupported schema node at {hint}: {json.dumps(node)[:200]}")
def _register(self, td: TypeDef) -> str:
existing = self.types.get(td.name)
if existing is not None:
if existing.kind != td.kind:
raise SchemaError(f"type name collision on {td.name}")
return td.name
self.types[td.name] = td
self._order.append(td.name)
return td.name
def _synth_string_enum(self, name: str, node: dict) -> str:
values = [
EnumValue(name=enum_ident(v), wire=v)
for v in node["enum"]
if v is not None
]
return self._register(TypeDef(name=name, kind="string_enum",
doc=node.get("description", ""), values=values))
def _synth_struct(self, name: str, node: dict) -> str:
if node.get("additionalProperties", False) is not False:
raise SchemaError(
f"{name}: object schemas must set additionalProperties:false — the daemon is "
"not allowed to trust unknown fields on the wire"
)
required = set(node.get("required", []))
fields: list[Field] = []
for prop, sub in node.get("properties", {}).items():
ref, nullable = self.type_ref(sub, name + pascal(prop))
fields.append(Field(name=prop, type=ref, required=prop in required,
nullable=nullable, doc=sub.get("description", "")))
return self._register(TypeDef(name=name, kind="struct",
doc=node.get("description", ""), fields=fields))
def named_type(self, sid: str, doc: dict) -> str:
"""Lower a top-level types/*.schema.json into a TypeDef and return its name."""
name = doc.get("title")
if not name:
raise SchemaError(f"{sid} has no title")
if name in self.types:
return name
source = self.source_of.get(sid, "")
node, _ = self._split_nullable(doc)
t = node.get("type")
if t == "string" and "enum" in node:
# Placeholder first: enums cannot recurse, but registering early keeps the
# ordering stable and mirrors the struct path below.
values = [EnumValue(name=enum_ident(v), wire=v) for v in node["enum"] if v is not None]
td = TypeDef(name, "string_enum", node.get("description", ""), values=values, source=source)
return self._register(td)
if t == "integer" and "x-enum" in node:
values = [EnumValue(name=e["name"], wire=e["value"], doc=e.get("doc", ""))
for e in node["x-enum"]]
td = TypeDef(name, "int_enum", node.get("description", ""), values=values, source=source)
return self._register(td)
if t == "object" and "properties" not in node:
ap = node.get("additionalProperties")
if not isinstance(ap, dict):
raise SchemaError(f"{sid}: object type with neither properties nor a typed additionalProperties")
inner, _ = self.type_ref(ap, name + "Value")
td = TypeDef(name, "map_alias", node.get("description", ""),
alias=TypeRef("map", inner=inner), source=source)
return self._register(td)
if t == "object":
# Reserve the name before descending so a nested synth cannot steal it.
self.types[name] = TypeDef(name, "struct", node.get("description", ""), source=source)
self._order.append(name)
required = set(node.get("required", []))
if node.get("additionalProperties", False) is not False:
raise SchemaError(f"{sid}: object schemas must set additionalProperties:false")
fields = []
for prop, sub in node.get("properties", {}).items():
ref, nullable = self.type_ref(sub, name + pascal(prop))
fields.append(Field(prop, ref, prop in required, nullable, sub.get("description", "")))
self.types[name].fields = fields
return name
raise SchemaError(f"{sid}: unsupported top-level type {t!r}")
# ---------------------------------------------------------------------- driver
def load() -> Contract:
loader = Loader()
root = loader.root.parent
version = (root / "VERSION").read_text().strip()
# Named types first, so their names win over any synthesised ones.
for sid in sorted(loader.by_id):
if "/types/" in sid:
loader.named_type(sid, loader.by_id[sid])
methods: list[Method] = []
for sid in sorted(loader.by_id):
if "/methods/" not in sid:
continue
doc = loader.by_id[sid]
name = doc["title"]
base = pascal(name)
props = doc.get("properties", {})
for half in ("params", "result"):
if half not in props:
raise SchemaError(f"{sid}: method schema must define both params and result")
params, _ = loader.type_ref(props["params"], base + "Params")
result, _ = loader.type_ref(props["result"], base + "Result")
transports = doc.get("x-transports")
if not transports:
raise SchemaError(f"{sid}: x-transports is required")
if "x-privileged" not in doc:
raise SchemaError(f"{sid}: x-privileged is required")
methods.append(Method(
name=name, doc=doc.get("description", ""), params=params, result=result,
privileged=bool(doc["x-privileged"]), transports=list(transports),
deadline_ms=int(doc.get("x-deadlineMs", 5000)),
errors=list(doc.get("x-errors", [])),
ws_restrictions=list(doc.get("x-wsRestrictions", [])),
))
events: list[Event] = []
for sid in sorted(loader.by_id):
if "/events/" not in sid:
continue
doc = loader.by_id[sid]
name = doc["title"]
ident = pascal(name[len("event."):] if name.startswith("event.") else name) + "Event"
params, _ = loader.type_ref(doc["properties"]["params"], ident)
events.append(Event(name=name, doc=doc.get("description", ""), params=params,
max_rate_hz=doc.get("x-maxRateHz")))
error_codes = loader.types["ErrorCode"].values
ordered = [loader.types[n] for n in loader._order]
return Contract(version=version, types=ordered, methods=methods, events=events,
error_codes=error_codes)
def dependencies(td: TypeDef) -> set[str]:
"""Named types `td` mentions directly."""
out: set[str] = set()
def walk(ref: TypeRef | None) -> None:
if ref is None:
return
if ref.kind == "named" and ref.name:
out.add(ref.name)
walk(ref.inner)
for f in td.fields:
walk(f.type)
walk(td.alias)
return out
def topo_sorted(types: list[TypeDef]) -> list[TypeDef]:
"""Definition order for languages that need a type declared before it is used.
The contract forbids recursive types, so a cycle here means a schema bug and is
raised rather than broken arbitrarily.
"""
by_name = {t.name: t for t in types}
state: dict[str, int] = {}
order: list[TypeDef] = []
def visit(name: str, trail: list[str]) -> None:
mark = state.get(name, 0)
if mark == 2:
return
if mark == 1:
raise SchemaError("recursive type: " + " -> ".join(trail + [name]))
state[name] = 1
for dep in sorted(dependencies(by_name[name])):
if dep in by_name:
visit(dep, trail + [name])
state[name] = 2
order.append(by_name[name])
for t in types:
visit(t.name, [])
return order
if __name__ == "__main__":
c = load()
print(f"contract {c.version}: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events")
for t in c.types:
detail = f"{len(t.fields)} fields" if t.kind == "struct" else f"{len(t.values)} values"
print(f" {t.kind:12} {t.name:34} {detail}")