Files
logos-test-modules/test-fullapi-ext-module-cpp/metadata.json
T
Dario LipicarandClaude Opus 5 d4c0d04644 feat(conformance): the LIDL type conformance matrix (#28)
* fix(fullapi-ui-qml): normalize event payloads to native JS

Qt exposes a list-type event payload (e.g. a [tstr] event) to QML as a
non-native JS *sequence* (Array.isArray === false), so the plugin's
deepEqual — which uses Array.isArray — rejected received list payloads
even when their contents matched (scalars and objects were unaffected).

JSON round-trip the received payload to native JS arrays/objects, exactly
how method results already arrive (logos.callModule returns a JSON string
this plugin JSON.parses). Scalars pass through unchanged.

This unblocks the events step of the basecamp-fullapi-ui-qml doctest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(conformance): the LIDL type matrix — case table + xfail registry

One question per cell: does a value of LIDL type T, in position P, survive
provider R -> consumer K intact?

`full_api` had 31 methods and 14 events and the native check made SIX
assertions against them. The rest was cardinality — the C++ proxy reports
`intList=3 uintList=2`, which passes whatever the elements became — or an
aggregate `ALL_OK` asserted over the COMBINED output of both providers, so one
provider alone could satisfy it while the other was broken. That is not a
weak test suite, it is a structural one: with per-consumer hand-written checks,
covering a type costs work in every consumer, so it does not happen.

conformance/cases.json is the language-neutral table every driver replays.
conformance/known.json is the xfail registry. Both live here, with the
providers they describe; the drivers live with the client each one uses (the
py driver is in logos-logoscore-py, which already depends on this repo).

What the table adds beyond what existed: the whole VALUE axis. Boundary
(int64 min/max, uint64 max, 2^53+1, all 256 byte values, embedded NUL, empty
string/bytes/list/map), hostile (-1 into a uint, 3.7 into an int, mixed-type
array elements) and adversarial (a user map whose only key is `_bytes`; a map
carrying `__logos_pending_call__`). Every expectation was MEASURED against both
providers, not assumed — several claims that looked obvious were wrong.

Four properties make it honest:
  - a red cell is a COORDINATE (`[uint]/method_arg/test_fullapi_rust/py`),
    never an aggregate token;
  - every case runs against BOTH providers and their answers are compared to
    each other independently of `expect` — that differential needs nobody to
    know the right answer in advance, and it is what surfaced the `void`
    divergence;
  - a known-broken cell is a registry entry, and one that starts passing is an
    `xpass` that FAILS the run, so a fix cannot land unnoticed;
  - coverage is computed from the .lidl: a declared (type, position) with no
    case fails the run. That is the guard against 31-methods-6-assertions
    recurring.

Adding a type now costs one LIDL line, one impl method per provider and N table
rows — zero per-consumer cost.

Four defects are registered with their measurements (known.json):
  M1/M1b a uint64 above int64max degrades to a double once nested in a
         container; exact as a top-level scalar. The C++ provider LOOKS green
         for [uint] because its typed decode coerces the double back — the Rust
         provider takes it untyped and reports the loss faithfully. Same defect,
         one surface masking it.
  M2     `void`: the C++ provider answers JSON true, the Rust one fails the
         call outright.
  M3/M4  the `_bytes` and `__logos_pending_call__` keys are forgeable from user
         data — one silently reinterprets a map as bytes, the other hijacks the
         call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(conformance): multi-argument arity + a check that the 5 contract copies agree

Two of the six positions had ZERO coverage: an argument in slot k of n, and an
event parameter in slot k of n. Every `full_api` method took 0 or 1 argument and
every event exactly 1, so nothing anywhere exercised positional dispatch — and
both generators emit positional code (`args.at(i)` / `args::as_*(args, i)`).
A `bstr` had also never appeared anywhere but first.

  method echoTriple(i: int, s: tstr, b: bstr) -> tstr
  method fireTripleEvent(i: int, s: tstr, b: bstr) -> bool
  event  tripleEvent(i: int, s: tstr, b: bstr)

The return is a digest — `i=<decimal>|s=<utf8>|b=<lowercase hex>` — so ONE
comparison pins all three values AND their order: swap two arguments and it
changes. A container return would have confounded "the arguments landed in the
right slots" with "the container encoding survived".

Measured, since the multi-parameter EVENT path had never run at runtime
anywhere and was worth checking rather than assuming: both providers answer
`i=-7|s=hé|b=00ff`, and the event arrives as {arg0, arg1, arg2} with the bstr
(NUL + high byte) decoded. The driver now compares the ordered argument list,
so slot order is part of the assertion.

Coverage now distinguishes `method_arg` from `method_arg@k`: a sole argument
cannot catch a generator that mixes up positional slots, so they are different
cells. A multi-argument case declares the (type, position) PAIRS it covers —
`int`@0, `tstr`@1, `bstr`@2 — rather than the cross-product of its type list
and position list, which would claim cells it never exercises.

Second half: check_contract_copies.py.

`full_api` exists in FIVE hand-maintained copies — the C++ provider's impl
header (the contract is derived from it), the Rust provider's .lidl, the shared
interface .lidl, the C++ consumer's .h, and the Rust proxy's re-export .lidl —
and nothing enforced that they agree. Adding a method to one and forgetting
another produces no error anywhere: each side compiles against its own copy and
the matrix only talks to the providers, so the drift surfaces much later as
"the interface does not have that method". Adding this arity surface meant
editing all five by hand, which is exactly the moment to add the check.

It compares method and event SIGNATURES (name, parameter types in order, return
type) in LIDL spelling, mapping C++ declarations through a closed table. An
unrecognised C++ spelling FAILS rather than being skipped — silently ignoring a
type is how a checker like this becomes decorative. Verified it catches both a
missing method and the subtler drift of two parameters silently reordered.

Matrix: 79 cases x 2 providers, green. fullapi chain check and the module suite
still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(conformance): the full_api_ext contract — records, bytes at depth, typed maps

The composite tail of the matrix. These types cannot go in `full_api`: it is
implemented by both providers, the C++ one is header-first, and the C++ cdylib
backend's typeSupported() gate rejects records and [bstr] BY NAME while its
impl-header parser skips `struct` entirely — a header-first C++ provider cannot
even declare a record. Adding them there would break test_fullapi_cpp's build
rather than test anything. So: a separate contract, Rust-first, with one
provider and therefore no differential column. That is a gap, recorded as one,
not a design choice.

New: test-fullapi-ext-module-rust (a contract-first Rust cdylib), ext-cases.json,
known-ext.json, and a `conformance-matrix-ext` check. 20 cases, full contract
coverage, green with 9 registered xfails.

It found three defects on its first run — all measured, none assumed:

E1  A bstr NESTED in a container is UTF-8 mangled. `00 80 ff` comes back as
    `00 EF BF BD EF BF BD` — every byte >= 0x80 replaced by U+FFFD and
    re-encoded. Through a [bstr], a {tstr:bstr}, a {tstr:[bstr]}, and a record's
    bstr field. A top-level bstr is exact on every provider, so the loss is
    nesting-specific. This is precisely what the canonical {"_bytes"} tag was
    introduced to prevent (protocol #21/#23), defeated one level down.

    Also reachable through the EXISTING full_api surface — echoMap/echoList/
    echoAny with a tagged value inside — so three cells and registry entry M5
    were added to the main table too, where they are red on BOTH providers.

    The corruption is INBOUND, deducible rather than guessed: echoBlob decodes
    the field into Vec<u8> and re-encodes it with the same tagged encoder the
    scalar path uses, and returned SIX bytes for a three-byte input. It
    faithfully re-tagged an already-replaced value, so neither the module nor
    its return path is where the bytes are lost.

E2  An EMPTY bstr nested in a container arrives as `null` and fails the call:
    `expected bytes at arg0[1].payload, got null`. Distinct from E1 — dropped
    rather than corrupted, which is the louder failure mode. A top-level empty
    bstr is exact.

E3  M1 (uint64 above int64max degrading to a double once nested) seen through a
    record field, with the exact path: `expected integer at arg0.n, got number`.

Neither E1 nor E2 is fixed here. Both are wire-representation questions in the
same family as M1, and I could not localize the hop by reading — the protocol's
own JSON/QVariant bridge handles bytes and unsigned correctly and documents the
hazard. Guessing a site and "fixing" it would be worse than recording what was
measured.

The driver's provider set is now data-driven (`--modules NAME=DIR`), so a table
with a different provider set runs through the same driver rather than a second
one; the differential simply has nothing to compare when a table has one
provider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(conformance): a C++ ext provider — the ext table gets its differential

test_fullapi_ext_cpp mirrors test_fullapi_ext_rust method for method, so the
composite tail of the matrix (records, bytes at depth, typed maps, nested
composites) is now checked on BOTH surfaces instead of one. It is header-first
like test_fullapi_cpp: the records are C++ structs in the impl header and the
contract is derived from them, which is possible only because the impl-header
parser learned `struct` (logos-cpp-sdk 3fd6841).

What the second provider immediately bought, in its first run:

  - {tstr:int}, {tstr:tstr} and [[int]] round-trip EXACTLY on C++. Those types
    were previously not expressible there at all — the gate rejected them by
    name — so this is new coverage, not a re-check.

  - the differential surfaced that the two providers DISAGREE on every
    bytes-at-depth cell, and in an informative direction: the C++ codec
    type-checks the byte field and REJECTS the mangled value
    (`expected bytes at arg0.payload, got string`), while the Rust side takes
    [bstr] untyped (serde_json::Value) and passes the corruption through. Same
    defect (E1), loud on one surface and silent on the other. Fixing E1 makes
    both green; until then the C++ behaviour is the better one.

The registry is now built FROM the measurement rather than by hand, with
per-case provider precision: 4 of E1's 9 cases fail on only one provider, and
registering them against both made the passing provider report `xpass` — the
registry manufacturing a failure. The driver honours `per_case_providers`.

One correction worth recording. I first wrote this module contract-first
(interface: "cdylib" + codegen.impl_class) and it SIGSEGV'd on the first call;
a minimal one-method version crashed identically, and so did a build with the
completely unmodified pinned generator — which looked like proof that the
contract-first C++ cdylib flavour is broken on master, since no module in the
workspace uses it. It was not. My CMakeLists came from the Rust template, which
has no SOURCES because the crate is a staticlib; without it the C++ impl
translation unit is never compiled, the plugin links with the impl's symbols
undefined and crashes on first call instead of failing to build. The comment in
the CMakeLists now says so.

20 cases x 2 providers, green with 22 registered xfails. The 5 full_api contract
copies still agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(conformance): E1/E2 are fixed by the canonical codec — verified, not merged

E1 (a bstr nested in a container UTF-8 mangled), E2 (an empty nested bstr
arriving as null) and M1/E3 (a uint64 above int64max degrading to a double once
nested) are ALL fixed by logos-protocol `feat/canonical-codec` — the branch that
folds the Qt and plain-wire copies into one canonical LIDL<->JSON codec. That
branch is 3 commits, a clean fast-forward onto master, and its own 199 tests
pass.

Measured against it, not predicted:

  full_api : 11 cells flip to xpass (every M1 and M5 cell)
  ext      : ALL 22 xfails flip to xpass, and the differential goes from
             10 pass / 9 xfail to 19/19 — the two ext providers now agree
             completely

So the answer to "fix E1 and E2" is that the fix already exists and was never
landed. The entries stay registered here because the workspace pins the PRE-fix
protocol, which is what CI runs; when the re-pin lands they report xpass and
these entries must be deleted — the registry's own forcing function.

How it was found, since the investigation was misleading in an instructive way:
every layer I tested in isolation came back CLEAN — the QVariant/json bridge,
the plain transport's QVariant/RpcValue conversion, QDataStream marshalling, the
whole CLI-to-daemon chain. That was because I was compiling the LOCAL protocol,
which already has the fix, while the running daemon used the PINNED build.
Probes on both sides of the boundary settled it in one run: core_service saw the
tagged value and the module received a mangled string.

One consequence to expect at re-pin time, recorded in each hostile case's `why`:
the Rust provider becomes STRICT. echoUint(-1), echoInt(3.7), echoBool(1), a
mixed-element [tstr], a scalar for [any] and a scalar for {tstr:any} all change
from a coerced value to dispatch_failed, because the host stops coercing before
the module's LIDL-type validation sees the value. That is the more correct
behaviour — the provider now enforces its declared types — but it IS a semantic
change, and the pinned expectations will go red until updated.

Not re-pinned here: protocol sits deep in the tree and that semantic change
should be a deliberate, reviewed call rather than a side effect of this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(conformance): retire what the codec fixed, and register what it did not

The re-pin (logos-protocol 362b03f via cpp-sdk 3d322bd, module-builder,
logoscore-cli) landed the canonical codec, so the registry has to be settled.

Retired, because they now pass:
  M1 / M1b   a uint64 above int64max degrading to a double once nested
  M5         a bstr nested in a container UTF-8 mangled
  E1/E2/E3   the same two defects through the ext contract, plus M1 seen
             through a record field

The ext table is now FULLY green — 40/40, no xfails, differential 19/19. Its
cases stay exactly where they are: they were red cells, they are now regression
guards, which is the whole point of pinning a defect as a case rather than a
comment.

Registered, because it did NOT pass — M6:

  echoUint(2^64-1)  method ->  18446744073709551615   exact
  uintEvent(2^64-1) event  ->  1.8446744073709552e+19 degraded

Same value, same process, one hop later. The canonical codec fixed the method
path; an event payload leaves the module by a different route and still loses a
value whose type its container does not declare.

Worth saying how M6 surfaced: `event/uint/boundary` was one of M1's cases, so
deleting M1 wholesale turned it from a registered xfail into a hard failure. The
registry caught a fix that covered most of an entry's cases but not all of them
— exactly the thing an xfail list is supposed to prevent you from waving
through.

The six hostile expectations are re-measured, not adjusted to taste: the Rust
provider now answers dispatch_failed where it used to coerce (-1 for a uint, 3.7
for an int, 1 for a bool, a mixed-element [tstr], a scalar for [any] or
{tstr:any}), because the host stops coercing before the module's LIDL-type
validation sees the value. The C++ side stays lenient, so the divergence moved
rather than closed — each case's `why` records that.

full_api: 156 pass / 8 xfail (M2 void, M3/M4 the forgeable reserved keys, M6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: re-pin onto the merged protocol/cpp-sdk chain

  logos-module-builder  -> ab0c776  (protocol 362b03f, cpp-sdk 3d322bd)
  logos-liblogos        -> 5c8b9f0  (protocol 362b03f, after #167)
  logos-logoscore-cli   -> f4753dd

This makes test_fullapi_ext_cpp build: its records need the impl-header parser
and cdylib codec from cpp-sdk 3d322bd, which reach here through module-builder.

test_fullapi_ext_rust still does NOT build — its Blob/Wrapper come from the Rust
provider codegen in logos-rust-sdk#29, which is still open. module-builder pins
rust-sdk a55fdac (pre-records), so that module needs: rust-sdk#29 merged ->
module-builder bumps rust-sdk -> re-pin here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: re-pin onto the merged rust-sdk records support

  logos-module-builder -> 9ac3235  (logos-rust-sdk 2eabc95, via #167)
  logos-logoscore-cli  -> a143727  (64-bit call args, #74)

This is the commit that makes test_fullapi_ext_rust build: its Blob/Wrapper
structs come from the Rust provider codegen in logos-rust-sdk#29, which reaches
here only through module-builder's by-rev rust-sdk pin.

All four conformance providers now build against a single merged closure:
test_fullapi_cpp, test_fullapi_rust, test_fullapi_ext_cpp, test_fullapi_ext_rust.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 22:37:26 -03:00

26 lines
693 B
JSON

{
"name": "test_fullapi_ext_cpp",
"version": "1.0.0",
"type": "core",
"category": "testing",
"description": "C++ mirror of test_fullapi_ext_rust, HEADER-FIRST like test_fullapi_cpp: the contract is derived from the impl header, records included. Keeps the two ext providers comparable cell for cell.",
"author": "Logos Core Team",
"main": "test_fullapi_ext_cpp_plugin",
"interface": "universal",
"dependencies": [],
"include": [],
"capabilities": [],
"nix": {
"external_libraries": [],
"packages": {
"build": [],
"runtime": [
"nlohmann_json"
]
},
"cmake": {
"find_packages": [],
"extra_include_dirs": []
}
}
}