From 086998f9868c1eb3e6f5e9cb31bea8c3de75ff80 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:29:16 +0100 Subject: [PATCH] feat(abi): generate the Zig and Rust wire enums from the normative Idris2 ABI (#120) The 16-connector wire contract existed in five hand-maintained copies that agreed only by discipline. This makes three of them generated from the normative Idris2 ABI, so drift becomes impossible rather than merely detected. New: - src/Hypatia/ABI/Gen.idr + src/abi/hypatia-abi-gen.ipkg -- an Idris2 executable that IMPORTS Hypatia.ABI.Types and EVALUATES it. It never parses .idr text. Because Gen is the mainfile, --total is genuinely re-checked for it. - ffi/zig/src/connector_generated.zig - clients/rust/hypatia-client/src/connector_generated.rs - ffi/connectors.json is now generated (its "Golden source-of-truth" claim contradicted the ABI-authority ruling and is gone) - `just abi-gen` - .github/workflows/abi-codegen-drift.yml -- regenerates into a temp dir and diffs. Never regenerate-in-place-and-`git diff`: measured, that reads GREEN when the generator silently no-ops. - .github/workflows/zig.yml zigIdent/rustIdent live in Gen, not in the ABI -- the normative module must not know about its consumers. Both are total with one clause per constructor, so adding a connector without naming it is a BUILD failure, not a CI diff. The hand-written files keep their tests. A generated test re-asserts its own source and proves nothing. Also closes instance 21 of the guard/consumer trap: the Elixir guard read src/abi/Types.idr, a byte-identical copy compiled by nothing, so the live ABI could drift and the guard would stay green. It now reads src/Hypatia/ABI/Types.idr. A mutant proved the fix: renaming a connector in the live module reddens the guard while the dead copy is untouched. The guard also gained a wire-id check. A mutant showed the name-only version stayed GREEN through an id swap -- names and their order are unchanged by a renumber, so a name-only guard sleeps through a C ABI break. ffi/zig/build.zig now roots unified-api-adapter.zig. It was reachable from no test root, so its comptime 16-connector assertion and its four tests had never executed anywhere. `zig build test` goes from 3 tests to 7. Measurements worth recording: - `zig build test` is RED on main for pre-existing reasons: three of four roots fail to compile against the Zig 0.16 std (16 call sites, six removed or relocated symbols) plus an `opaque` type with fields. zig.yml therefore runs the maximal green subset, not the whole build. - `cargo test --workspace` is RED on main for an unrelated pre-existing reason: integration/Cargo.toml requires testcontainers ^0.28 AND testcontainers-modules ^0.15, which needs testcontainers ^0.27. That is unsatisfiable at the manifest level, so no lockfile can fix it and hypatia-client never gets compiled. Its five tests were verified against the generated enum in a standalone crate: 5 passed, 0 failed. - Consequently, before this PR NEITHER "independent pin" on the wire contract executed anywhere. The drift gate is currently the only executing pin, and zig.yml makes the comptime assertion the second. actions.lock: two workflow keys added by hand, purely additive (6 insertions, 0 deletions). Both actions were already present, so no new dependency entry. `gh actions-lock --verify-local` goes from "2 of 32 workflows failed: 4 not-pinned" to "All 32 workflows have complete lockfile coverage". Fix mode was not used -- it has previously de-pinned SHAs and invented invalid refs. Every gate was mutation-tested; all reds were drift or assertion failures, not compile errors: 1. hand-edit a generated Zig wire name -> gate RED, guard RED 2. swap wire ids 3<->4 in the live ABI, do not regenerate -> gate RED (3 of 3 files), guard RED 3. add a 17th connector without naming it in Gen -> generator BUILD fails (not covering) then name it and regenerate -> Zig comptime RED, Rust count_is_sixteen + from_id_round_trip RED 4. generator replaced by a silent no-op exiting 0 -> gate prints "connectors emitted: 0" / "files compared: 0 of 3" and fails Positive control on a clean tree: gate rc=0 printing 16 / 3 of 3, guard 4/4, zig 4/4, rust 5/5, `just abi-gen` byte-identical and idempotent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0113HQM9LVGkNCzU1WwkJZSV --- .github/workflows/abi-codegen-drift.yml | 164 +++++++++ .github/workflows/actions.lock | 6 + .github/workflows/zig.yml | 124 +++++++ Justfile | 15 + clients/rust/hypatia-client/src/connector.rs | 110 +------ .../hypatia-client/src/connector_generated.rs | 97 ++++++ clients/rust/hypatia-client/src/lib.rs | 2 + ffi/connectors.json | 4 +- ffi/zig/build.zig | 16 + ffi/zig/src/connector_generated.zig | 101 ++++++ ffi/zig/src/unified-api-adapter.zig | 99 +----- src/Hypatia/ABI/Gen.idr | 311 ++++++++++++++++++ src/Hypatia/ABI/Types.idr | 20 +- src/abi/hypatia-abi-gen.ipkg | 23 ++ test/unified-api-adapter-contract_test.exs | 123 +++++-- 15 files changed, 1001 insertions(+), 214 deletions(-) create mode 100644 .github/workflows/abi-codegen-drift.yml create mode 100644 .github/workflows/zig.yml create mode 100644 clients/rust/hypatia-client/src/connector_generated.rs create mode 100644 ffi/zig/src/connector_generated.zig create mode 100644 src/Hypatia/ABI/Gen.idr create mode 100644 src/abi/hypatia-abi-gen.ipkg diff --git a/.github/workflows/abi-codegen-drift.yml b/.github/workflows/abi-codegen-drift.yml new file mode 100644 index 00000000..505928b3 --- /dev/null +++ b/.github/workflows/abi-codegen-drift.yml @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# ABI codegen drift gate. +# +# Re-runs `hypatia-abi-gen` against the normative Idris2 ABI and fails if any +# generated file in the tree differs from what the generator emits now. +# +# Deliberately has NO `paths:` filter. `verify-proofs.yml` -- the obvious home +# for this job -- filters on `src/abi/**`, `src/Hypatia/ABI/**`, `verify/**` +# and friends, and lists none of the generated targets, so a hand-edit to +# `ffi/zig/src/connector_generated.zig` would not have fired it at all. An +# unfiltered workflow also stays eligible to be made a required check without +# deadlocking PRs that touch none of those paths. + +name: ABI codegen drift + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: read-all + +jobs: + abi-codegen-drift: + name: abi-codegen-drift + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + + env: + IDRIS2_VERSION: v0.7.0 + + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + + - name: Install Chez Scheme + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y chezscheme + + - name: Cache Idris 2 install + id: cache-idris + uses: actions/cache@v6.1.0 + with: + path: | + /usr/local/bin/idris2 + /usr/local/bin/idris2_app + /usr/local/lib/idris2 + ~/.idris2 + # Same key as verify-proofs.yml on purpose: this job shares that + # workflow's warm cache rather than paying a second ~20-min + # bootstrap. Keep the `-2` suffix in step with it. + key: idris2-${{ env.IDRIS2_VERSION }}-${{ runner.os }}-2 + + - name: Build Idris 2 from source + if: steps.cache-idris.outputs.cache-hit != 'true' + run: | + set -euo pipefail + git clone --depth 1 --branch "${IDRIS2_VERSION}" \ + https://github.com/idris-lang/Idris2 /tmp/idris2 + cd /tmp/idris2 + make bootstrap SCHEME=chezscheme + sudo make install PREFIX=/usr/local + idris2 --version + + - name: Verify Idris is on PATH + run: idris2 --version + + - name: Build the generator + run: | + set -euo pipefail + cd src/abi + idris2 --build hypatia-abi-gen.ipkg + + - name: Regenerate into a temp dir and compare + run: | + set -euo pipefail + # NOTE: `set -o pipefail` (via -euo) is load-bearing below. A + # pipeline's exit status is its LAST command's, so `generator | tee` + # would report success for a generator that died. + + OUT="${RUNNER_TEMP}/abi-gen" + rm -rf "${OUT}" + mkdir -p "${OUT}" + + # Regenerate into a TEMP dir, never in place. Regenerating in place + # and running `git diff` means a generator that silently failed to + # run leaves an empty diff -- which reads as green. + ./build/abi-gen/exec/hypatia-abi-gen \ + --out-dir "${OUT}" \ + --abi-hash "$(git hash-object src/Hypatia/ABI/Types.idr)" \ + | tee "${RUNNER_TEMP}/gen.log" + + emitted=$(sed -n 's/^connectors emitted:[[:space:]]*//p' "${RUNNER_TEMP}/gen.log") + emitted=${emitted:-0} + + # published path <- emitted filename + targets="ffi/zig/src/connector_generated.zig:connector_generated.zig + clients/rust/hypatia-client/src/connector_generated.rs:connector_generated.rs + ffi/connectors.json:connectors.json" + + compared=0 + drifted=0 + total=0 + for pair in ${targets}; do + total=$((total + 1)) + tracked=${pair%%:*} + emitted_file="${OUT}/${pair##*:}" + + if [ ! -f "${tracked}" ]; then + echo "::error::tracked file missing: ${tracked}" + drifted=$((drifted + 1)) + continue + fi + if [ ! -s "${emitted_file}" ]; then + echo "::error::generator emitted nothing for ${tracked} (${emitted_file} missing or zero bytes)" + drifted=$((drifted + 1)) + continue + fi + + compared=$((compared + 1)) + if diff -u "${tracked}" "${emitted_file}"; then + echo "✓ ${tracked}" + else + echo "::error::${tracked} is out of date -- run \`just abi-gen\` and commit the result" + drifted=$((drifted + 1)) + fi + done + + # The denominator. A gate that cannot state how much it checked is + # not evidence -- an empty output dir must fail loudly, not print + # "ok" because the comparison loop had nothing to iterate over. + echo "connectors emitted: ${emitted}" + echo "files compared: ${compared} of ${total}" + { + echo "### ABI codegen drift" + echo "" + echo "- connectors emitted: \`${emitted}\`" + echo "- files compared: \`${compared} of ${total}\`" + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${emitted}" -eq 0 ]; then + echo "::error::generator emitted 0 connectors -- it did not run, or the ABI is empty" + exit 1 + fi + if [ "${compared}" -ne "${total}" ]; then + echo "::error::compared ${compared} of ${total} generated files" + exit 1 + fi + if [ "${drifted}" -ne 0 ]; then + echo "::error::${drifted} generated file(s) drifted from the Idris2 ABI" + exit 1 + fi + echo "No drift: every generated file matches src/Hypatia/ABI/Types.idr" diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index a6cde667..1cd4e702 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -3,6 +3,9 @@ # Docs: https://gh.io/actions-lockfile version: 'v0.0.2' workflows: + '.github/workflows/abi-codegen-drift.yml': + - 'actions/cache@v6.1.0' + - 'actions/checkout@v7.0.1' '.github/workflows/bridge-gate.yml': - 'actions/checkout@v7.0.1' '.github/workflows/build-gossamer-gui.yml': @@ -115,6 +118,9 @@ workflows: - 'actions/checkout@v7.0.1' - 'actions/setup-java@v6.0.1' - 'actions/upload-artifact@v7.0.1' + '.github/workflows/zig.yml': + - 'actions/cache@v6.1.0' + - 'actions/checkout@v7.0.1' dependencies: 'actions/attest-build-provenance@v4.2.2': ref: 'v4.2.2' diff --git a/.github/workflows/zig.yml b/.github/workflows/zig.yml new file mode 100644 index 00000000..458dd8f4 --- /dev/null +++ b/.github/workflows/zig.yml @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Zig wire-contract tests. +# +# Scope is deliberately narrow: `zig test ffi/zig/src/unified-api-adapter.zig`, +# NOT `zig build test`. +# +# Why. The adapter carries the `comptime` 16-connector assertion and the four +# wire-contract tests, and it was reachable from no test root at all -- none of +# `main.zig`, `json_writer.zig`, `file_ops.zig` or `test/integration_test.zig` +# imports it -- so those assertions had never executed anywhere, locally or in +# CI. `build.zig` now roots it too, but `zig build test` is independently RED +# on `main` for pre-existing reasons (below), so gating on the whole build +# would land a workflow that is red on arrival and says nothing about this +# contract. +# +# Measured 2026-09-22 with Zig 0.16.0: `zig build test` reports +# "4/11 steps succeeded (3 failed); 7/7 tests passed". Three of the four +# pre-existing roots (main.zig, file_ops.zig, test/integration_test.zig) fail to +# COMPILE against the 0.16 standard library -- 16 call sites across six removed +# or relocated symbols (std.fs.cwd, std.posix.getenv, std.fs.openFileAbsolute, +# std.fs.createFileAbsolute, std.fs.deleteFileAbsolute, std.time.timestamp), +# plus an `opaque` type declared with fields at main.zig:46. That is a +# pre-existing un-migrated tree, not a regression from this change. +# +# The 7 passing tests are json_writer.zig's 3 and this adapter's 4 -- so +# adding the adapter root took `zig build test` from 3 tests to 7. +# +# This job runs the maximal subset that is green today. Widen it to +# `zig build test` once the tree is migrated to the 0.16 std API. +# +# No `paths:` filter, deliberately -- so it stays eligible to be made a +# required check without the filter/required deadlock. + +name: Zig + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: read-all + +jobs: + zig-test: + name: zig test (wire contract) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + env: + # Matches the owner's local toolchain + # (developer/tools/opt/mise/installs/zig/0.16.0). + ZIG_VERSION: '0.16.0' + # Official shasum for zig-x86_64-linux-0.16.0.tar.xz. Pinned rather than + # installed via a third-party action: it adds no `uses:` entry to + # actions.lock, and it cannot float the way `mise.toml`'s `zig = "latest"` + # does. + ZIG_SHA256: '70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00' + + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + + - name: Cache the Zig toolchain + id: cache-zig + uses: actions/cache@v6.1.0 + with: + path: ~/.local/zig + key: zig-${{ env.ZIG_VERSION }}-x86_64-linux-${{ env.ZIG_SHA256 }} + + - name: Install Zig + if: steps.cache-zig.outputs.cache-hit != 'true' + run: | + set -euo pipefail + tarball="zig-x86_64-linux-${ZIG_VERSION}.tar.xz" + curl -fsSL -o "/tmp/${tarball}" \ + "https://ziglang.org/download/${ZIG_VERSION}/${tarball}" + echo "${ZIG_SHA256} /tmp/${tarball}" | sha256sum -c - + mkdir -p ~/.local/zig + tar -xJf "/tmp/${tarball}" -C ~/.local/zig --strip-components=1 + + - name: Put Zig on PATH + run: echo "$HOME/.local/zig" >> "$GITHUB_PATH" + + - name: Verify the toolchain + run: | + set -euo pipefail + zig version + test "$(zig version)" = "${ZIG_VERSION}" + + - name: Run the wire-contract tests + run: | + set -euo pipefail + # `zig test` writes its summary to stderr; keep both streams. + zig test ffi/zig/src/unified-api-adapter.zig 2>&1 | tee "${RUNNER_TEMP}/zig-test.log" + + # The denominator. `zig test` on a file containing no `test` blocks + # exits 0 -- a green tick that proves nothing. Insist that it says + # how many it ran, and that the number is not zero. + summary=$(grep -Eo '[0-9]+ of [0-9]+ test[s]? passed|All [0-9]+ tests passed' \ + "${RUNNER_TEMP}/zig-test.log" | tail -1 || true) + count=$(printf '%s' "${summary}" | grep -Eo '[0-9]+' | tail -1 || true) + count=${count:-0} + + echo "zig tests run: ${count}" + { + echo "### Zig wire contract" + echo "" + echo "- root: \`ffi/zig/src/unified-api-adapter.zig\`" + echo "- tests run: \`${count}\`" + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${count}" -lt 1 ]; then + echo "::error::zig test reported ${count} tests -- the wire-contract tests did not run" + exit 1 + fi diff --git a/Justfile b/Justfile index 5638c009..d7b5545b 100644 --- a/Justfile +++ b/Justfile @@ -56,6 +56,21 @@ invariant-path *ARGS: compile-abi: cd src/abi && idris2 --build hypatia-abi.ipkg +# Regenerate the wire contract from the normative Idris2 ABI, then commit the +# result. The provenance stamp is a content hash of Types.idr, so ANY byte +# change to the ABI -- a comment included -- reddens abi-codegen-drift until +# this is re-run. That is deliberate: touching the normative ABI should force +# you to look at what it generated. +abi-gen: + cd src/abi && idris2 --build hypatia-abi-gen.ipkg + mkdir -p build/abi-gen/out + ./build/abi-gen/exec/hypatia-abi-gen \ + --out-dir build/abi-gen/out \ + --abi-hash "$(git hash-object src/Hypatia/ABI/Types.idr)" + cp build/abi-gen/out/connector_generated.zig ffi/zig/src/connector_generated.zig + cp build/abi-gen/out/connector_generated.rs clients/rust/hypatia-client/src/connector_generated.rs + cp build/abi-gen/out/connectors.json ffi/connectors.json + # Compile Idris2 verification proofs compile-verify: cd verify && idris2 --build hypatia-verify.ipkg diff --git a/clients/rust/hypatia-client/src/connector.rs b/clients/rust/hypatia-client/src/connector.rs index ac6acc7a..01a688e7 100644 --- a/clients/rust/hypatia-client/src/connector.rs +++ b/clients/rust/hypatia-client/src/connector.rs @@ -1,108 +1,18 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// UnifiedApiAdapter enum — mirror of the Zig -// `Connector` enum in `ffi/zig/src/unified-api-adapter.zig` and the Idris2 -// `Connector` data type in `src/abi/Types.idr`. +// UnifiedApiAdapter enum — re-exported from the generated wire contract. // -// **Wire ordering is load-bearing.** The integer value of each variant -// is the C ABI id used by `hypatia_connector_name(id)` and -// `hypatia_connector_port(id, base)`. Renumbering this enum without -// updating the Zig and Idris2 sides is a hard ABI break — the -// Idris2 `connectorCount : length allConnectors = 16` proof catches -// counts but not orderings. - -use serde::{Deserialize, Serialize}; - -/// The sixteen protocol connectors exposed by the Hypatia -/// unified-api-adapter surface. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[repr(u8)] -pub enum Connector { - // ---- Core 12 ---- - Grpc = 0, - GraphQl = 1, - Rest = 2, - FlatBuffers = 3, - Bebop = 4, - JsonRpc = 5, - WebSocket = 6, - Mqtt = 7, - Trpc = 8, - CapnProto = 9, - Soap = 10, - VerisimdbRest = 11, - // ---- Umoja-substrate 4 ---- - Bsp = 12, - Scip = 13, - Ipfs = 14, - ArrowFlight = 15, -} - -/// The total number of connectors. Pinned at 16 by the -/// `comptime` assertion in `unified-api-adapter.zig` and the -/// `connectorCount : length allConnectors = 16` proof in -/// `src/abi/Types.idr`. -pub const CONNECTOR_COUNT: usize = 16; - -impl Connector { - /// Canonical wire name. Must agree with the Zig side and the - /// Idris2 ABI; tests assert all sixteen. - pub fn name(self) -> &'static str { - match self { - Connector::Grpc => "grpc", - Connector::GraphQl => "graphql", - Connector::Rest => "rest", - Connector::FlatBuffers => "flatbuffers", - Connector::Bebop => "bebop", - Connector::JsonRpc => "jsonrpc", - Connector::WebSocket => "websocket", - Connector::Mqtt => "mqtt", - Connector::Trpc => "trpc", - Connector::CapnProto => "capnproto", - Connector::Soap => "soap", - Connector::VerisimdbRest => "verisimdb-rest", - Connector::Bsp => "bsp", - Connector::Scip => "scip", - Connector::Ipfs => "ipfs", - Connector::ArrowFlight => "arrow-flight", - } - } - - /// All sixteen connectors in wire order. - pub fn all() -> [Connector; CONNECTOR_COUNT] { - [ - Connector::Grpc, - Connector::GraphQl, - Connector::Rest, - Connector::FlatBuffers, - Connector::Bebop, - Connector::JsonRpc, - Connector::WebSocket, - Connector::Mqtt, - Connector::Trpc, - Connector::CapnProto, - Connector::Soap, - Connector::VerisimdbRest, - Connector::Bsp, - Connector::Scip, - Connector::Ipfs, - Connector::ArrowFlight, - ] - } - - /// Try to construct a connector from its wire id. Returns - /// `None` for ids outside `0..16`. - pub fn from_id(id: u8) -> Option { - Self::all().get(id as usize).copied() - } +// The normative source is the Idris2 ABI `src/Hypatia/ABI/Types.idr`. +// `connector_generated.rs` is emitted from it by `just abi-gen`, as are the +// Zig enum in `ffi/zig/src/connector_generated.zig` and `ffi/connectors.json`. +// Do not edit the generated file; edit the ABI and regenerate. +// +// The tests below are hand-maintained on purpose. A generated test would +// re-assert its own source and prove nothing; these pin the wire contract +// independently of the generator. - /// Bound port for this connector under a given base port. - /// Layout matches the V-lang reference (`base + id + 1`). - pub fn port(self, base_port: u16) -> u16 { - base_port + (self as u8 as u16) + 1 - } -} +pub use crate::connector_generated::{Connector, CONNECTOR_COUNT}; #[cfg(test)] mod tests { diff --git a/clients/rust/hypatia-client/src/connector_generated.rs b/clients/rust/hypatia-client/src/connector_generated.rs new file mode 100644 index 00000000..acdf7910 --- /dev/null +++ b/clients/rust/hypatia-client/src/connector_generated.rs @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// @generated by hypatia-abi-gen from src/Hypatia/ABI/Types.idr +// ABI source blob: 788493e6f54b6d436ea551dbb5d34c6bc0d819ab +// DO NOT EDIT -- run `just abi-gen` and commit the result. + +//! The sixteen-protocol wire contract, generated from the normative +//! Idris2 ABI. `connector.rs` re-exports these items and keeps its own +//! hand-written tests. + +use serde::{Deserialize, Serialize}; + +/// A wire protocol supported by the Hypatia unified API adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(u8)] +pub enum Connector { + Grpc = 0, + GraphQl = 1, + Rest = 2, + FlatBuffers = 3, + Bebop = 4, + JsonRpc = 5, + WebSocket = 6, + Mqtt = 7, + Trpc = 8, + CapnProto = 9, + Soap = 10, + VerisimdbRest = 11, + Bsp = 12, + Scip = 13, + Ipfs = 14, + ArrowFlight = 15, +} + +/// Computed by the generator from `Hypatia.ABI.Types.allConnectors`. +pub const CONNECTOR_COUNT: usize = 16; + +impl Connector { + /// Stable wire name. Load-bearing: do not rename. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Grpc => "grpc", + Self::GraphQl => "graphql", + Self::Rest => "rest", + Self::FlatBuffers => "flatbuffers", + Self::Bebop => "bebop", + Self::JsonRpc => "jsonrpc", + Self::WebSocket => "websocket", + Self::Mqtt => "mqtt", + Self::Trpc => "trpc", + Self::CapnProto => "capnproto", + Self::Soap => "soap", + Self::VerisimdbRest => "verisimdb-rest", + Self::Bsp => "bsp", + Self::Scip => "scip", + Self::Ipfs => "ipfs", + Self::ArrowFlight => "arrow-flight", + } + } + + /// Every connector, in wire order. + #[must_use] + pub const fn all() -> [Connector; CONNECTOR_COUNT] { + [ + Connector::Grpc, + Connector::GraphQl, + Connector::Rest, + Connector::FlatBuffers, + Connector::Bebop, + Connector::JsonRpc, + Connector::WebSocket, + Connector::Mqtt, + Connector::Trpc, + Connector::CapnProto, + Connector::Soap, + Connector::VerisimdbRest, + Connector::Bsp, + Connector::Scip, + Connector::Ipfs, + Connector::ArrowFlight, + ] + } + + /// Recover a connector from its C ABI wire id. + #[must_use] + pub fn from_id(id: u8) -> Option { + Self::all().get(id as usize).copied() + } + + /// Port layout: `base_port + wire_id + 1`. + #[must_use] + pub const fn port(self, base_port: u16) -> u16 { + base_port + (self as u8 as u16) + 1 + } +} diff --git a/clients/rust/hypatia-client/src/lib.rs b/clients/rust/hypatia-client/src/lib.rs index a205aa22..26e73d0f 100644 --- a/clients/rust/hypatia-client/src/lib.rs +++ b/clients/rust/hypatia-client/src/lib.rs @@ -27,6 +27,8 @@ // type-safe end-to-end. pub mod client; +mod connector_generated; + pub mod connector; pub mod error; pub mod types; diff --git a/ffi/connectors.json b/ffi/connectors.json index f4c93839..22a4799f 100644 --- a/ffi/connectors.json +++ b/ffi/connectors.json @@ -1,6 +1,8 @@ { "_spdx": "MPL-2.0", - "_note": "Golden source-of-truth for the unified-api-adapter wire contract. The Zig enum (ffi/zig/src/unified-api-adapter.zig), the Idris2 ABI (src/abi/Types.idr), and the Rust client (clients/rust/hypatia-client/src/connector.rs) MUST all agree with this list, in this exact order; id is the C ABI wire id. Drift is guarded by test/unified-api-adapter-contract_test.exs. Wire ordering is load-bearing -- do not renumber.", + "_generated": "by hypatia-abi-gen from src/Hypatia/ABI/Types.idr; DO NOT EDIT -- run `just abi-gen` and commit the result", + "_abi_source_blob": "788493e6f54b6d436ea551dbb5d34c6bc0d819ab", + "_note": "Wire manifest for the unified-api-adapter. The normative source is the Idris2 ABI (src/Hypatia/ABI/Types.idr); this file, ffi/zig/src/connector_generated.zig and clients/rust/hypatia-client/src/connector_generated.rs are all generated from it. id is the C ABI wire id and ordering is load-bearing -- do not renumber by hand.", "connectors": [ { "id": 0, "name": "grpc" }, { "id": 1, "name": "graphql" }, diff --git a/ffi/zig/build.zig b/ffi/zig/build.zig index 0f3f8150..35b5c697 100644 --- a/ffi/zig/build.zig +++ b/ffi/zig/build.zig @@ -105,9 +105,25 @@ pub fn build(b: *std.Build) void { }), }); + // Unit tests for the unified API adapter. + // + // The adapter was reachable from NO test root: main.zig, json_writer.zig, + // file_ops.zig and integration_test.zig none of them import it, so its + // `comptime` 16-connector assertion and its four tests never executed -- + // locally or in CI. It is a leaf (it imports connector_generated.zig and + // connectors/*.zig by relative path), so it needs no module wiring. + const adapter_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/unified-api-adapter.zig"), + .target = target, + .optimize = optimize, + }), + }); + const test_step = b.step("test", "Run all tests"); test_step.dependOn(&b.addRunArtifact(main_tests).step); test_step.dependOn(&b.addRunArtifact(json_tests).step); test_step.dependOn(&b.addRunArtifact(file_ops_tests).step); test_step.dependOn(&b.addRunArtifact(integration_tests).step); + test_step.dependOn(&b.addRunArtifact(adapter_tests).step); } diff --git a/ffi/zig/src/connector_generated.zig b/ffi/zig/src/connector_generated.zig new file mode 100644 index 00000000..033408f6 --- /dev/null +++ b/ffi/zig/src/connector_generated.zig @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// @generated by hypatia-abi-gen from src/Hypatia/ABI/Types.idr +// ABI source blob: 788493e6f54b6d436ea551dbb5d34c6bc0d819ab +// DO NOT EDIT -- run `just abi-gen` and commit the result. + +//! The sixteen-protocol wire contract, generated from the normative +//! Idris2 ABI. `unified-api-adapter.zig` re-exports these declarations +//! and keeps its own hand-written tests. + +pub const Connector = enum(u8) { + grpc = 0, + graphql = 1, + rest = 2, + flatbuffers = 3, + bebop = 4, + jsonrpc = 5, + websocket = 6, + mqtt = 7, + trpc = 8, + capnproto = 9, + soap = 10, + verisimdb_rest = 11, + bsp = 12, + scip = 13, + ipfs = 14, + arrow_flight = 15, + + /// Stable wire name. Load-bearing: do not rename. + pub fn name(self: Connector) [*:0]const u8 { + return switch (self) { + .grpc => "grpc", + .graphql => "graphql", + .rest => "rest", + .flatbuffers => "flatbuffers", + .bebop => "bebop", + .jsonrpc => "jsonrpc", + .websocket => "websocket", + .mqtt => "mqtt", + .trpc => "trpc", + .capnproto => "capnproto", + .soap => "soap", + .verisimdb_rest => "verisimdb-rest", + .bsp => "bsp", + .scip => "scip", + .ipfs => "ipfs", + .arrow_flight => "arrow-flight", + }; + } + + /// The connector's implementation module, selected at comptime. + pub fn module(comptime self: Connector) type { + return switch (self) { + .grpc => @import("connectors/grpc.zig"), + .graphql => @import("connectors/graphql.zig"), + .rest => @import("connectors/rest.zig"), + .flatbuffers => @import("connectors/flatbuffers.zig"), + .bebop => @import("connectors/bebop.zig"), + .jsonrpc => @import("connectors/jsonrpc.zig"), + .websocket => @import("connectors/websocket.zig"), + .mqtt => @import("connectors/mqtt.zig"), + .trpc => @import("connectors/trpc.zig"), + .capnproto => @import("connectors/capnproto.zig"), + .soap => @import("connectors/soap.zig"), + .verisimdb_rest => @import("connectors/verisimdb_rest.zig"), + .bsp => @import("connectors/bsp.zig"), + .scip => @import("connectors/scip.zig"), + .ipfs => @import("connectors/ipfs.zig"), + .arrow_flight => @import("connectors/arrow_flight.zig"), + }; + } +}; + +/// Derived from the enum itself, never a hand-written literal. +pub const CONNECTOR_COUNT: usize = @typeInfo(Connector).@"enum".fields.len; + +/// Every connector, in wire order. +pub const ALL: [CONNECTOR_COUNT]Connector = .{ + .grpc, + .graphql, + .rest, + .flatbuffers, + .bebop, + .jsonrpc, + .websocket, + .mqtt, + .trpc, + .capnproto, + .soap, + .verisimdb_rest, + .bsp, + .scip, + .ipfs, + .arrow_flight, +}; + +/// Port layout: `base_port + wire_id + 1`. +pub fn portFor(base_port: u16, c: Connector) u16 { + return base_port + @as(u16, @intFromEnum(c)) + 1; +} diff --git a/ffi/zig/src/unified-api-adapter.zig b/ffi/zig/src/unified-api-adapter.zig index d7e2db9f..8e43fd8f 100644 --- a/ffi/zig/src/unified-api-adapter.zig +++ b/ffi/zig/src/unified-api-adapter.zig @@ -42,58 +42,22 @@ const std = @import("std"); // ============================================================ -// 16-variant tagged Connector enum +// 16-variant tagged Connector enum — GENERATED // ============================================================ // -// Stable wire ordering — DO NOT renumber. The Idris2 ABI mirrors this -// in `src/abi/Types.idr` and the Rust client in -// `clients/rust/hypatia-client/src/connector.rs`. All three must agree. - -pub const Connector = enum(u8) { - // Core 12 - grpc = 0, - graphql = 1, - rest = 2, - flatbuffers = 3, - bebop = 4, - jsonrpc = 5, - websocket = 6, - mqtt = 7, - trpc = 8, - capnproto = 9, - soap = 10, - verisimdb_rest = 11, - // Umoja-substrate 4 - bsp = 12, - scip = 13, - ipfs = 14, - arrow_flight = 15, - - pub fn name(self: Connector) [*:0]const u8 { - return switch (self) { - .grpc => "grpc", - .graphql => "graphql", - .rest => "rest", - .flatbuffers => "flatbuffers", - .bebop => "bebop", - .jsonrpc => "jsonrpc", - .websocket => "websocket", - .mqtt => "mqtt", - .trpc => "trpc", - .capnproto => "capnproto", - .soap => "soap", - .verisimdb_rest => "verisimdb-rest", - .bsp => "bsp", - .scip => "scip", - .ipfs => "ipfs", - .arrow_flight => "arrow-flight", - }; - } -}; +// Stable wire ordering — DO NOT renumber, and do not edit by hand. +// The normative source is the Idris2 ABI `src/Hypatia/ABI/Types.idr`; +// `connector_generated.zig` is emitted from it by `just abi-gen`, as are +// the Rust client's enum and `ffi/connectors.json`. +// +// This file re-exports the generated declarations and keeps its own +// hand-written `comptime` assertion and tests below. A generated test +// would re-assert its own source and prove nothing. + +const gen = @import("connector_generated.zig"); -/// The total number of connectors. Compile-time constant; tests -/// assert that it equals 16. -pub const CONNECTOR_COUNT: usize = @typeInfo(Connector).@"enum".fields.len; +pub const Connector = gen.Connector; +pub const CONNECTOR_COUNT = gen.CONNECTOR_COUNT; // ============================================================ // Per-connector stub modules @@ -105,22 +69,6 @@ pub const CONNECTOR_COUNT: usize = @typeInfo(Connector).@"enum".fields.len; // Real socket/encoding work belongs in the connector modules; this // file only routes. -const grpc = @import("connectors/grpc.zig"); -const graphql = @import("connectors/graphql.zig"); -const rest = @import("connectors/rest.zig"); -const flatbuffers = @import("connectors/flatbuffers.zig"); -const bebop = @import("connectors/bebop.zig"); -const jsonrpc = @import("connectors/jsonrpc.zig"); -const websocket = @import("connectors/websocket.zig"); -const mqtt = @import("connectors/mqtt.zig"); -const trpc = @import("connectors/trpc.zig"); -const capnproto = @import("connectors/capnproto.zig"); -const soap = @import("connectors/soap.zig"); -const verisimdb_rest = @import("connectors/verisimdb_rest.zig"); -const bsp = @import("connectors/bsp.zig"); -const scip = @import("connectors/scip.zig"); -const ipfs = @import("connectors/ipfs.zig"); -const arrow_flight = @import("connectors/arrow_flight.zig"); // ============================================================ // Suite — port-table for the sixteen connectors @@ -143,22 +91,11 @@ pub const UnifiedApiAdapter = struct { /// log-only stub matching the V reference; real bind happens /// when each module is fleshed out individually. pub fn startAll(self: UnifiedApiAdapter) void { - grpc.start(self.portFor(.grpc)); - graphql.start(self.portFor(.graphql)); - rest.start(self.portFor(.rest)); - flatbuffers.start(self.portFor(.flatbuffers)); - bebop.start(self.portFor(.bebop)); - jsonrpc.start(self.portFor(.jsonrpc)); - websocket.start(self.portFor(.websocket)); - mqtt.start(self.portFor(.mqtt)); - trpc.start(self.portFor(.trpc)); - capnproto.start(self.portFor(.capnproto)); - soap.start(self.portFor(.soap)); - verisimdb_rest.start(self.portFor(.verisimdb_rest)); - bsp.start(self.portFor(.bsp)); - scip.start(self.portFor(.scip)); - ipfs.start(self.portFor(.ipfs)); - arrow_flight.start(self.portFor(.arrow_flight)); + // Dispatch is generated: `gen.ALL` and `Connector.module()` come from + // the Idris2 ABI, so a new connector needs no edit here. + inline for (gen.ALL) |c| { + c.module().start(self.portFor(c)); + } } }; diff --git a/src/Hypatia/ABI/Gen.idr b/src/Hypatia/ABI/Gen.idr new file mode 100644 index 00000000..df8e299d --- /dev/null +++ b/src/Hypatia/ABI/Gen.idr @@ -0,0 +1,311 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- Hypatia ABI — wire-contract code generator. +-- +-- Emits the Zig `Connector` enum, the Rust `Connector` enum and the +-- `ffi/connectors.json` wire manifest from the *normative* Idris2 ABI in +-- `Hypatia.ABI.Types`. +-- +-- This module **evaluates** `allConnectors`, `connectorWireId`, +-- `connectorName` and `connectorPort`. It never parses `.idr` text, so the +-- generated artefacts cannot disagree with the type that produced them. +-- +-- The two naming functions (`zigIdent`, `rustIdent`) live here rather than in +-- `Hypatia.ABI.Types`: the normative ABI must not know about its consumers. +-- Both are total with one clause per constructor, so adding a seventeenth +-- connector without naming it is a *compile* error -- strictly stronger than +-- a CI diff. +-- +-- Usage: hypatia-abi-gen --out-dir --abi-hash + +module Hypatia.ABI.Gen + +import Hypatia.ABI.Types + +import Data.List +import Data.String +import System +import System.File + +%default total + +-------------------------------------------------------------------------------- +-- Small helpers +-------------------------------------------------------------------------------- + +||| Join with a separator. (Avoids depending on a particular `base` revision's +||| spelling of `joinBy`.) +joinWith : String -> List String -> String +joinWith sep xs = concat (intersperse sep xs) + +||| Render a list of lines, newline-terminated. +lines' : List String -> String +lines' xs = joinWith "\n" xs ++ "\n" + +quoted : String -> String +quoted s = "\"" ++ s ++ "\"" + +-------------------------------------------------------------------------------- +-- Consumer-side naming +-------------------------------------------------------------------------------- + +||| Zig `enum(u8)` member names, snake_case. +||| +||| Transcribed verbatim from `ffi/zig/src/unified-api-adapter.zig` as it stood +||| before generation. These are a public wire-adjacent API: do not rename. +||| Note they are NOT derivable from `connectorName` in general -- the +||| relationship happens to be `-` -> `_` today, and stating it as a rule would +||| bake an accident into the generator. +public export +zigIdent : Connector -> String +zigIdent GRPC = "grpc" +zigIdent GraphQL = "graphql" +zigIdent REST = "rest" +zigIdent FlatBuffers = "flatbuffers" +zigIdent Bebop = "bebop" +zigIdent JsonRpc = "jsonrpc" +zigIdent WebSocket = "websocket" +zigIdent MQTT = "mqtt" +zigIdent TRPC = "trpc" +zigIdent CapnProto = "capnproto" +zigIdent SOAP = "soap" +zigIdent VeriSimDBRest = "verisimdb_rest" +zigIdent BSP = "bsp" +zigIdent SCIP = "scip" +zigIdent IPFS = "ipfs" +zigIdent ArrowFlight = "arrow_flight" + +||| Rust `#[repr(u8)]` variant names, UpperCamelCase. +||| +||| Transcribed verbatim from `clients/rust/hypatia-client/src/connector.rs`. +||| Genuinely not derivable: `GRPC` -> `Grpc`, `GraphQL` -> `GraphQl`, +||| `VeriSimDBRest` -> `VerisimdbRest`. +public export +rustIdent : Connector -> String +rustIdent GRPC = "Grpc" +rustIdent GraphQL = "GraphQl" +rustIdent REST = "Rest" +rustIdent FlatBuffers = "FlatBuffers" +rustIdent Bebop = "Bebop" +rustIdent JsonRpc = "JsonRpc" +rustIdent WebSocket = "WebSocket" +rustIdent MQTT = "Mqtt" +rustIdent TRPC = "Trpc" +rustIdent CapnProto = "CapnProto" +rustIdent SOAP = "Soap" +rustIdent VeriSimDBRest = "VerisimdbRest" +rustIdent BSP = "Bsp" +rustIdent SCIP = "Scip" +rustIdent IPFS = "Ipfs" +rustIdent ArrowFlight = "ArrowFlight" + +-------------------------------------------------------------------------------- +-- Provenance banner +-------------------------------------------------------------------------------- + +||| SPDX must be the first line of every emitted file: the repository's +||| `validate-spdx.sh` pre-commit hook does not know these files are generated. +banner : (comment : String) -> (abiHash : String) -> List String +banner c abiHash = + [ c ++ " SPDX-License-Identifier: MPL-2.0" + , c ++ " Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) " + , c + , c ++ " @generated by hypatia-abi-gen from src/Hypatia/ABI/Types.idr" + , c ++ " ABI source blob: " ++ abiHash + , c ++ " DO NOT EDIT -- run `just abi-gen` and commit the result." + ] + +-------------------------------------------------------------------------------- +-- Zig +-------------------------------------------------------------------------------- + +zigFile : (abiHash : String) -> String +zigFile abiHash = lines' $ + banner "//" abiHash ++ + [ "" + , "//! The sixteen-protocol wire contract, generated from the normative" + , "//! Idris2 ABI. `unified-api-adapter.zig` re-exports these declarations" + , "//! and keeps its own hand-written tests." + , "" + , "pub const Connector = enum(u8) {" + ] ++ map enumMember allConnectors ++ + [ "" + , " /// Stable wire name. Load-bearing: do not rename." + , " pub fn name(self: Connector) [*:0]const u8 {" + , " return switch (self) {" + ] ++ map nameArm allConnectors ++ + [ " };" + , " }" + , "" + , " /// The connector's implementation module, selected at comptime." + , " pub fn module(comptime self: Connector) type {" + , " return switch (self) {" + ] ++ map moduleArm allConnectors ++ + [ " };" + , " }" + , "};" + , "" + , "/// Derived from the enum itself, never a hand-written literal." + , "pub const CONNECTOR_COUNT: usize = @typeInfo(Connector).@\"enum\".fields.len;" + , "" + , "/// Every connector, in wire order." + , "pub const ALL: [CONNECTOR_COUNT]Connector = .{" + ] ++ map allMember allConnectors ++ + [ "};" + , "" + , "/// Port layout: `base_port + wire_id + 1`." + , "pub fn portFor(base_port: u16, c: Connector) u16 {" + , " return base_port + @as(u16, @intFromEnum(c)) + 1;" + , "}" + ] + where + enumMember : Connector -> String + enumMember c = " " ++ zigIdent c ++ " = " ++ show (connectorWireId c) ++ "," + + nameArm : Connector -> String + nameArm c = " ." ++ zigIdent c ++ " => " ++ quoted (connectorName c) ++ "," + + moduleArm : Connector -> String + moduleArm c = + " ." ++ zigIdent c ++ " => @import(\"connectors/" ++ zigIdent c ++ ".zig\")," + + allMember : Connector -> String + allMember c = " ." ++ zigIdent c ++ "," + +-------------------------------------------------------------------------------- +-- Rust +-------------------------------------------------------------------------------- + +rustFile : (abiHash : String) -> String +rustFile abiHash = lines' $ + banner "//" abiHash ++ + [ "" + , "//! The sixteen-protocol wire contract, generated from the normative" + , "//! Idris2 ABI. `connector.rs` re-exports these items and keeps its own" + , "//! hand-written tests." + , "" + , "use serde::{Deserialize, Serialize};" + , "" + , "/// A wire protocol supported by the Hypatia unified API adapter." + , "#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]" + , "#[repr(u8)]" + , "pub enum Connector {" + ] ++ map variant allConnectors ++ + [ "}" + , "" + , "/// Computed by the generator from `Hypatia.ABI.Types.allConnectors`." + , "pub const CONNECTOR_COUNT: usize = " ++ show (length allConnectors) ++ ";" + , "" + , "impl Connector {" + , " /// Stable wire name. Load-bearing: do not rename." + , " #[must_use]" + , " pub const fn name(self) -> &'static str {" + , " match self {" + ] ++ map nameArm allConnectors ++ + [ " }" + , " }" + , "" + , " /// Every connector, in wire order." + , " #[must_use]" + , " pub const fn all() -> [Connector; CONNECTOR_COUNT] {" + , " [" + ] ++ map allMember allConnectors ++ + [ " ]" + , " }" + , "" + , " /// Recover a connector from its C ABI wire id." + , " #[must_use]" + , " pub fn from_id(id: u8) -> Option {" + , " Self::all().get(id as usize).copied()" + , " }" + , "" + , " /// Port layout: `base_port + wire_id + 1`." + , " #[must_use]" + , " pub const fn port(self, base_port: u16) -> u16 {" + , " base_port + (self as u8 as u16) + 1" + , " }" + , "}" + ] + where + variant : Connector -> String + variant c = " " ++ rustIdent c ++ " = " ++ show (connectorWireId c) ++ "," + + nameArm : Connector -> String + nameArm c = + " Self::" ++ rustIdent c ++ " => " ++ quoted (connectorName c) ++ "," + + allMember : Connector -> String + allMember c = " Connector::" ++ rustIdent c ++ "," + +-------------------------------------------------------------------------------- +-- JSON +-------------------------------------------------------------------------------- + +||| JSON has no comments, so provenance lives in `_generated`. +jsonFile : (abiHash : String) -> String +jsonFile abiHash = lines' $ + [ "{" + , " \"_spdx\": \"MPL-2.0\"," + , " \"_generated\": \"by hypatia-abi-gen from src/Hypatia/ABI/Types.idr; DO NOT EDIT -- run `just abi-gen` and commit the result\"," + , " \"_abi_source_blob\": " ++ quoted abiHash ++ "," + , " \"_note\": \"Wire manifest for the unified-api-adapter. The normative source is the Idris2 ABI (src/Hypatia/ABI/Types.idr); this file, ffi/zig/src/connector_generated.zig and clients/rust/hypatia-client/src/connector_generated.rs are all generated from it. id is the C ABI wire id and ordering is load-bearing -- do not renumber by hand.\"," + , " \"connectors\": [" + , joinWith ",\n" (map entry allConnectors) + , " ]" + , "}" + ] + where + entry : Connector -> String + entry c = + " { \"id\": " ++ show (connectorWireId c) + ++ ", \"name\": " ++ quoted (connectorName c) ++ " }" + +-------------------------------------------------------------------------------- +-- Driver +-------------------------------------------------------------------------------- + +record Opts where + constructor MkOpts + outDir : Maybe String + abiHash : Maybe String + +parseArgs : List String -> Opts -> Opts +parseArgs [] acc = acc +parseArgs ("--out-dir" :: v :: rest) acc = parseArgs rest ({ outDir := Just v } acc) +parseArgs ("--abi-hash" :: v :: rest) acc = parseArgs rest ({ abiHash := Just v } acc) +parseArgs (_ :: rest) acc = parseArgs rest acc + +writeOne : (path : String) -> (content : String) -> IO Bool +writeOne path content = do + Right () <- writeFile path content + | Left err => do + putStrLn ("hypatia-abi-gen: cannot write " ++ path ++ ": " ++ show err) + pure False + pure True + +emitAll : (outDir : String) -> (abiHash : String) -> IO () +emitAll outDir abiHash = do + ok1 <- writeOne (outDir ++ "/connector_generated.zig") (zigFile abiHash) + ok2 <- writeOne (outDir ++ "/connector_generated.rs") (rustFile abiHash) + ok3 <- writeOne (outDir ++ "/connectors.json") (jsonFile abiHash) + -- The denominator, per the standing vacuous-gate rule: a generator that + -- cannot state how much it emitted is not evidence that it ran. + putStrLn ("connectors emitted: " ++ show (length allConnectors)) + putStrLn ("files written: " ++ show (length (filter id [ok1, ok2, ok3])) ++ " of 3") + if ok1 && ok2 && ok3 + then pure () + else exitFailure + +main : IO () +main = do + args <- getArgs + let opts = parseArgs (drop 1 args) (MkOpts Nothing Nothing) + case (opts.outDir, opts.abiHash) of + (Just o, Just h) => emitAll o h + _ => do + putStrLn "usage: hypatia-abi-gen --out-dir --abi-hash " + putStrLn "" + putStrLn " --out-dir directory to write the three generated files into" + putStrLn " --abi-hash `git hash-object src/Hypatia/ABI/Types.idr`" + exitFailure diff --git a/src/Hypatia/ABI/Types.idr b/src/Hypatia/ABI/Types.idr index 40d2ed6c..788493e6 100644 --- a/src/Hypatia/ABI/Types.idr +++ b/src/Hypatia/ABI/Types.idr @@ -144,12 +144,20 @@ record OutcomeRecord where -- UnifiedApiAdapter — sixteen protocol adapters -- ============================================================ -- --- Mirrors the Zig enum at `ffi/zig/src/unified-api-adapter.zig` and the Rust --- enum at `clients/rust/hypatia-client/src/connector.rs`. Wire --- ordering is load-bearing — the integer value of each variant is --- the C ABI id used by `hypatia_connector_name(id)`. Do not --- renumber. The dependent-type proof `connectorWireIdInRange` --- below pins the count at exactly 16. +-- This is the NORMATIVE definition of the wire contract. The Zig enum in +-- `ffi/zig/src/connector_generated.zig`, the Rust enum in +-- `clients/rust/hypatia-client/src/connector_generated.rs` and the manifest +-- `ffi/connectors.json` are GENERATED from this module by `just abi-gen`; +-- edit this file, never them. (Until 2026-09-22 those were hand-maintained +-- copies and this comment described itself as mirroring them -- the direction +-- of authority was backwards.) +-- +-- Wire ordering is load-bearing — the integer value of each variant is +-- the C ABI id used by `hypatia_connector_name(id)`. Do not renumber. +-- The proof `connectorCount` below pins the count at exactly 16. +-- (An earlier version of this comment cited `connectorWireIdInRange`, which +-- has never existed in this module; `connectorCount` is the real pin, and it +-- pins the count, not the range.) -- -- Replaces the V-lang client at `api/v/hypatia.v` (deleted 2026-04-13). diff --git a/src/abi/hypatia-abi-gen.ipkg b/src/abi/hypatia-abi-gen.ipkg new file mode 100644 index 00000000..16c60182 --- /dev/null +++ b/src/abi/hypatia-abi-gen.ipkg @@ -0,0 +1,23 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- Hypatia ABI code generator: emits the Zig and Rust wire enums and the +-- connectors.json manifest from the normative Idris2 ABI. +-- +-- `sourcedir = ".."` resolves to `src/` (this ipkg lives in `src/abi/`), +-- exactly as hypatia-abi.ipkg does, so `Hypatia.ABI.Gen` is +-- `src/Hypatia/ABI/Gen.idr` and imports `src/Hypatia/ABI/Types.idr` -- the +-- live module, not the duplicate that sits next to this file. + +package hypatia-abi-gen +version = 0.5.0 +authors = "Jonathan D.A. Jewell" +brief = "Generates the unified-api-adapter wire contract from the Idris2 ABI" +depends = base >= 0.6.0 + , contrib +sourcedir = ".." +main = Hypatia.ABI.Gen +executable = hypatia-abi-gen +builddir = "../../build/abi-gen" +outputdir = "../../build/abi-gen/exec" +opts = "--total" diff --git a/test/unified-api-adapter-contract_test.exs b/test/unified-api-adapter-contract_test.exs index 4be6ed7f..47e8be84 100644 --- a/test/unified-api-adapter-contract_test.exs +++ b/test/unified-api-adapter-contract_test.exs @@ -3,51 +3,122 @@ defmodule Hypatia.UnifiedApiAdapterContractTest do @moduledoc false use ExUnit.Case, async: true - # Single-oracle drift guard for the unified-api-adapter wire contract. + # Drift guard for the unified-api-adapter wire contract. # - # `ffi/connectors.json` is the golden source-of-truth for the 16-connector - # surface. The Zig enum, the Idris2 ABI, and the Rust client each mirror it; - # this test reads the golden plus all three source files and fails if any - # mirror drifts in name or order. Wire ordering is load-bearing - # (see ffi/zig/src/unified-api-adapter.zig). + # The golden source is the **normative Idris2 ABI**, `src/Hypatia/ABI/Types.idr` + # -- the module that `src/abi/hypatia-abi.ipkg` actually compiles (its + # `sourcedir = ".."` resolves to `src/`). This guard previously read + # `src/abi/Types.idr`, a byte-identical copy that is compiled by nothing: the + # live ABI could drift and the guard would have stayed green. + # + # The three sources below are emitted from that golden by `just abi-gen`, so + # in normal operation they cannot disagree. This test is still worth running + # because it checks generated output against the source by a **different + # mechanism** -- regex scraping here, evaluation in the generator. Four + # generated files agreeing with each other would be vacuous; generated-vs- + # source by an independent method catches a bug in the generator itself. @root Path.expand("..", __DIR__) - @golden Path.join(@root, "ffi/connectors.json") + + @golden_path Path.join(@root, "src/Hypatia/ABI/Types.idr") + @golden_regex ~r/connectorName\s+\w+\s*=\s*"([a-z0-9-]+)"/ + + # Names alone do not pin the wire contract. Swapping two ids in + # `connectorWireId` leaves every name and their order untouched, so a + # name-only guard stays green through a C ABI break -- measured, not + # supposed: that exact mutant passed this file before these two lines + # existed. Scrape the constructor -> id and constructor -> name maps and + # compare the pair. + @golden_id_regex ~r/connectorWireId\s+(\w+)\s*=\s*(\d+)/ + @golden_ctor_name_regex ~r/connectorName\s+(\w+)\s*=\s*"([a-z0-9-]+)"/ @sources [ - {"Zig (unified-api-adapter.zig)", Path.join(@root, "ffi/zig/src/unified-api-adapter.zig"), + {"Zig (connector_generated.zig)", Path.join(@root, "ffi/zig/src/connector_generated.zig"), ~r/\.\w+\s*=>\s*"([a-z0-9-]+)"/}, - {"Idris2 (Types.idr)", Path.join(@root, "src/abi/Types.idr"), - ~r/connectorName\s+\w+\s*=\s*"([a-z0-9-]+)"/}, - {"Rust (connector.rs)", Path.join(@root, "clients/rust/hypatia-client/src/connector.rs"), - ~r/Connector::\w+\s*=>\s*"([a-z0-9-]+)"/} + {"Rust (connector_generated.rs)", + Path.join(@root, "clients/rust/hypatia-client/src/connector_generated.rs"), + ~r/Self::\w+\s*=>\s*"([a-z0-9-]+)"/}, + {"JSON (ffi/connectors.json)", Path.join(@root, "ffi/connectors.json"), + ~r/"name":\s*"([a-z0-9-]+)"/} ] - defp golden_connectors do - @golden |> File.read!() |> Jason.decode!() |> Map.fetch!("connectors") - end - defp names_in(path, regex) do regex |> Regex.scan(File.read!(path)) |> Enum.map(fn [_, name] -> name end) end - test "golden fixture lists the 16 connectors with sequential wire ids" do - conns = golden_connectors() - assert length(conns) == 16 - assert Enum.map(conns, & &1["id"]) == Enum.to_list(0..15) + defp golden_names, do: names_in(@golden_path, @golden_regex) + + # name => wire id, derived from the golden by joining the two Idris2 + # functions on their shared constructor. + defp golden_ids do + src = File.read!(@golden_path) + + ctor_to_name = + @golden_ctor_name_regex + |> Regex.scan(src) + |> Map.new(fn [_, ctor, name] -> {ctor, name} end) + + @golden_id_regex + |> Regex.scan(src) + |> Enum.flat_map(fn [_, ctor, id] -> + case Map.fetch(ctor_to_name, ctor) do + {:ok, name} -> [{name, String.to_integer(id)}] + :error -> [] + end + end) + |> Map.new() + end + + test "the golden ABI yields a non-empty connector list" do + # The denominator. A guard that cannot state how much it compared is not + # evidence: if this regex ever stops matching, every comparison below + # succeeds vacuously against an empty list. + names = golden_names() + assert names != [], "no connector names scraped from #{@golden_path}" + assert length(names) == length(Enum.uniq(names)), "duplicate connector names in the ABI" end - test "every language mirror matches the golden in name and order" do - golden_names = Enum.map(golden_connectors(), & &1["name"]) + test "every generated mirror matches the golden ABI in name and order" do + golden = golden_names() for {label, path, regex} <- @sources do + assert File.exists?(path), "#{label}: generated file missing at #{path}" names = names_in(path, regex) - assert length(names) == 16, - "#{label}: expected 16 connector names, found #{length(names)}" + assert names == golden, + "#{label} drifted from src/Hypatia/ABI/Types.idr -- run `just abi-gen`" <> + "\n golden: #{inspect(golden)}\n found: #{inspect(names)}" + end + end + + test "the JSON manifest numbers connectors sequentially from zero" do + conns = + Path.join(@root, "ffi/connectors.json") |> File.read!() |> Jason.decode!() + |> Map.fetch!("connectors") + + # Derived from the golden, never a hand-written 16: the count is pinned by + # `connectorCount = Refl`, the Zig comptime assertion and Rust's + # `count_is_sixteen`. A sixth hand-written literal would be drift surface. + n = length(golden_names()) + assert length(conns) == n + assert Enum.map(conns, & &1["id"]) == Enum.to_list(0..(n - 1)) + end + + test "the JSON manifest wire ids match the golden ABI connectorWireId" do + ids = golden_ids() + + # Denominator first: a join that silently produced an empty map would + # make every assertion below vacuous. + assert map_size(ids) == length(golden_names()), + "scraped #{map_size(ids)} wire ids but #{length(golden_names())} names from #{@golden_path}" + + conns = + Path.join(@root, "ffi/connectors.json") |> File.read!() |> Jason.decode!() + |> Map.fetch!("connectors") - assert names == golden_names, - "#{label} drifted from ffi/connectors.json\n golden: #{inspect(golden_names)}\n found: #{inspect(names)}" + for %{"id" => id, "name" => name} <- conns do + assert Map.fetch!(ids, name) == id, + "ffi/connectors.json gives #{name} id #{id}, the ABI gives #{Map.fetch!(ids, name)}" end end end