3 Commits
Author SHA1 Message Date
Dario Gabriel Lipicar 9470ffbda2 fix(qt-consumer): the call sites follow the lossless Qt type mapping
`[int]` / `[uint]` / `[float64]` / `[bool]` are QList<qlonglong> /
QList<qulonglong> / QList<double> / QList<bool> on the generated Qt consumer
surface now, not QVariantList. `[tstr]` (QStringList), `[any]` (QVariantList)
and `{tstr:any}` (QVariantMap) are unchanged — `any` is the one LIDL type with
no narrower Qt spelling.

Three modules in this repo consume full_api through that surface and stopped
compiling: test_fullapi_qtproxy (20 errors), test_fullapi_ui (8) and
test_uiqml_probe (3). The first two were reported; the third was not — it
reaches the same wrappers through `type: ui_qml`, which selects the Qt consumer
by a different rule than qtproxy's explicit `codegen.consumer_api_style: "qt"`.

Two things beyond the type spellings, because compiling is not the bar:

  * test_fullapi_qtproxy's renderer. renderVariant() switches on userType(),
    and QVariant has no implicit constructor for a typed QList, so the four
    list probes would not even have compiled as a QVariant. renderTypedList()
    renders the ELEMENTS through renderVariant(), which reproduces the previous
    QVariantList string byte-for-byte (`[i:1,i:2]`, `[B:t,B:f]`) so a recorded
    sync-vs-async diff still compares. The same four metatypes are also
    dispatched from renderVariant() itself, ahead of the switch, so a QVariant
    that ever does carry one cannot render as `?QList<qlonglong>:`.
  * test_uiqml_probe's report. logos::qvariantToNlohmann matches a CLOSED
    userType() set that a typed QList is not in, so
    QVariant::fromValue(QList<qulonglong>) dumps as `null` — the report would
    have silently lost the value it exists to show. boxed() puts the elements
    back into a QVariantList before dumping.

One real behavioural change is recorded rather than smoothed over: the probe's
NATIVE slots still declare QVariantList (that is what QML's JS->C++ conversion
produces, and measuring it is the point), so a hostile element — a double where
the contract says uint — used to ride out to the provider and be REFUSED by its
std decode. The surface type can no longer carry it, so narrowed() converts at
the boundary instead. The refusal is still measured: the QString slots decode
with fromJson<std::vector<T>> and answer REJECTED before forwarding.

This commit is LOCKSTEP with logos-cpp-sdk / logos-qt-sdk feat/lossless-qt-types
— these sources do not compile against the SDK pair on master, and the master
sources do not compile against the pair on the branch. Verified: all three
modules (and test_fullapi_ui's second codegen variant) build green against
logos-cpp-sdk 621772a + logos-qt-sdk 09c1a5b, and fail against both masters.
2026-08-22 18:18:54 -03:00
Dario LipicarandClaude Opus 5 626fbaa032 Migrate the last Qt fixtures to interface "universal", retire test_ipc_module, and repoint onto logos-qt-host (#44)
* feat: migrate the two provider-header fixtures to interface "universal"

dummy_module_000000 and test_ipc_new_api_module were the last users of
`logos-cpp-generator --provider-header` (LOGOS_PROVIDER / LOGOS_METHOD).

They were invisible to the "does anything still declare interface: provider?"
check that motivated removing that mode: neither sets the field. Both invoked
the generator BY HAND from a preConfigure hook in this flake, so the only way
to find them was to remove the flag and watch what broke.

Both now derive their contract from a plain impl header:
  * the hand-written *_loader.h plugin classes are gone (generated)
  * PROVIDER_HEADER / logos_provider_dispatch.cpp drop out of CMakeLists
  * test_ipc_new_api_module's 16 methods go from raw
    LogosAPIClient::invokeRemoteMethod("mod", "method", ...) — a typo in
    either string was a runtime null — to typed modules().<dep>.<method>()
  * its `triggeredBasicEvent` becomes a DECLARED logos_events: event, so
    consumers get a typed onTriggeredBasicEvent() accessor; it used to be
    emitted dynamically by name with no accessor at all

Method surface preserved, checked with lm before/after:
  * dummy: identical (noop(), 1 method)
  * ipc-new-api: same 16 names and arity. The only difference is int ->
    qlonglong on the three integer params, which is the LIDL 64-bit type
    contract every universal module already follows (the generated
    onMultiArgEvent in test_basic_module_cpp spells it the same way).

checks: ipc-new-api-tests PASS (the live end-to-end one), ipc-tests PASS,
unit-tests PASS.

KNOWN RED: unit-tests-new-api. Its 428-line suite drives the impl through the
legacy mock harness — impl.init(&api) plus a compiled logos_provider_dispatch.cpp
and qt_provider_object.cpp — none of which a universal module has. Porting it
means injecting LogosModules via _logosCoreSetLogosModulesPtr_ and switching
that derivation to --api-style lp. Left as its own change rather than deleted,
because dropping coverage is not a side effect to slip into this commit.

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

* test: port unit-tests-new-api to the universal/lp path

The suite drove the impl through the legacy provider harness — impl.init(&api)
plus a compiled logos_provider_dispatch.cpp — neither of which exists once the
module is `interface: "universal"`. Ported rather than deleted: 26 original
tests kept 1:1, 32 passing in total.

  * new_api_fixture.h does what the generated plugin glue does in production:
    injects the LogosModules umbrella via _logosCoreSetLogosModulesPtr_ and
    installs an emit callback. modules() blindly dereferences that pointer, so
    the injection is mandatory, not convenience.
  * the derivation emits lp-typed wrappers (--api-style lp; it defaulted to qt,
    which cannot bind to std-typed call sites) and consumes the deps'
    `headers-lp` variant rather than `include` (= headers-qt).
  * it also emits the generated `logos_events:` bodies. That is a LINK
    requirement: the impl calls triggeredBasicEvent(), whose body is generated.
    Only the events file is compiled in, not the 19K C-ABI export wrapper.

Verified the mock still intercepts, which was the whole risk — a non-
intercepting call returns a default-constructed value, so tests can pass
vacuously. It does: lp_invoke delegates to the SAME
LogosAPIClient::invokeRemoteMethod as the legacy path (logos_protocol.cpp:311),
and logos-protocol's own tests/protocol/test_lp_client.cpp already mocks
lp_invoke this way.

Two argument-representation notes, measured rather than assumed:
  * args reach MockStore JSON-round-tripped, so a non-negative int is
    QVariant(qulonglong), never QVariant(int). wasCalledWith still matches
    because Qt6 QVariant equality falls back to numericCompare for numeric
    metatypes — but numeric args are asserted via lastArgs() + a typed accessor
    anyway, which is the idiom logos-protocol's own lp tests use.
  * what WOULD break wasCalledWith is non-numeric drift (QStringList ->
    QVariantList, LogosResult -> QVariantMap). No param here has those types.

Six tests added where the port exposed gaps:
  * StdLogosResult success/error/map-field — the old suite asserted nothing
    about the result-carrying methods, and the Qt LogosResult -> StdLogosResult
    change (QVariant value -> nlohmann::json) is exactly what a type swap gets
    silently wrong. A LogosResult crosses the wire as its {success,value,error}
    OBJECT; my first attempt mocked a bare payload, which decodes to
    success == false — and made the "missing key returns empty" test pass for
    entirely the wrong reason. Both the positive case and a failed-call case
    now pin that distinction.
  * triggerBasicEvent asserts the typed emission and its payload; the event was
    previously emitted dynamically by name and untested.

Untouched and still red for reasons that predate this branch, confirmed by
building each at the baseline pins: checks `tests`, `thread-safety-tests` and
`qml-modules` (the last dies on `no member named 'add' in 'TestBasicModule'`).

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

* fix: restore the LEGACY unit-tests block to qt-typed dep headers

My D-a2 change switched basicInclude/extlibInclude to `headers-lp`, but the
replacement had no count and hit BOTH derivations — including the legacy
test_ipc_module one, which still generates its umbrella with the default
`--api-style qt`. The mismatch surfaced as `no matching constructor for
initialization of 'TestBasicModule'` in the generated logos_sdk.h.

Caught by re-running the check and attributing it with a control run rather
than assuming the newer capability_module override was to blame — it was not.

unit-tests PASSes again. Comment added so the two blocks are not "unified"
later without also moving the generator call.

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

* feat(extlib): migrate test_extlib_module to interface "universal"

The last external-library fixture still carrying a hand-written Qt plugin.
The impl is now a plain Qt-free class deriving LogosModuleContext; the
generator derives the LIDL from it and emits the glue, the dispatch and the
C-ABI exports. `test_extlib_module_interface.h` and `_plugin.{h,cpp}` are gone.

The external C library wiring — CMake builds lib/libstrutil.c into a static
`strutil` target and LINK_TARGETS it — is deliberately UNCHANGED. That is what
this fixture exists to exercise, and it is the part a universal migration
could plausibly have broken.

## Method surface, measured before and after with `lm methods --json`

Six methods, all preserved by name and arity. The only signature change is
`int` -> `qlonglong` on countChars/countChar, which is expected and correct:
LIDL numbers are 64-bit.

The two entries that disappear are Qt plumbing, not API — the `eventResponse`
signal and `initLogos(LogosAPI*)`. Neither is part of the module's contract,
and the already-migrated universal modules in this tree (dummy_module_000000,
test_ipc_new_api_module, test_basic_module_cpp) show the same shape.

## Verified

All six checks that reach this module, built by name through the workspace
flake with the local cpp-sdk / module-builder / plugin-qt / capability-module
overrides. Every one PASSes:

  ipc-tests           23 passed  (legacy test_ipc_module -> extlib, both raw
                                  invokeRemoteMethod and the generated qt
                                  wrapper)
  ipc-new-api-tests   23 passed  (universal consumer -> extlib over lp)
  async-tests          8 passed  (incl. asyncCallExtlibReverse)
  unit-tests          26 assertions (LEGACY qt-typed consumer compiled against
                                  the now-universal dep's `headers-qt` — the
                                  binding most at risk here, since a universal
                                  module's qt headers are generated from the
                                  derived LIDL rather than copied)
  unit-tests-new-api  32 assertions (lp-typed consumer, `headers-lp`)
  fullapi-tests        7 passed  (control; does not touch this module)

The module's OWN 12 integration assertions live in the `extlib` group of the
`tests` check, which is red at baseline for unrelated reasons. Rather than
skip that coverage, the group was run directly against the check's own
modulesDir and logoscore:

  TEST_GROUPS=extlib -> 12 passed, 0 failed, 1 skipped

That covers the strutil round-trips by exact value (reverseString(hello) ->
olleh, uppercaseString(FooBar) -> FOOBAR, countChar(aabaa, a) -> 4,
libVersion() -> 1.0.0), so the C library really is still linked and called.

## NOT verified

* `tests`, `thread-safety-tests` and `qml-modules` were not run. All three are
  red at baseline for reasons predating this branch (`qml-modules` dies on
  `no member named 'add' in 'TestBasicModule'` — test-qml-backend-module calls
  `.add()` on a module that exposes `addInts`). None of them is made better or
  worse by this commit; the extlib group inside `tests` was run manually
  instead, as described above.
* Linux. Everything here was built and run on aarch64-darwin only.
* countChar's degenerate empty-`ch` path is preserved by construction
  (empty -> NUL, which never matches) but is not covered by a test either
  before or after.

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

* feat(basic): migrate test_basic_module to interface "universal"

The provider fixture the whole suite is built around: one method per return
type, per parameter type and per argument count (0-5). Its plugin was the last
hand-written Qt loader among the non-QML fixtures. The impl is now a plain
Qt-free class deriving LogosModuleContext; `test_basic_module_interface.h` and
`_plugin.{h,cpp}` are gone, and the generator derives the LIDL from the impl
header and emits the glue, the dispatch, the typed event bodies and the C-ABI
exports.

## Method surface, measured before and after with `lm methods --json`

41 entries before, 39 after. Accounted for exactly:

  23  byte-identical
  13  `int` -> `qlonglong` only (expected; LIDL numbers are 64-bit)
   2  Qt plumbing REMOVED, not API: the `eventResponse` signal and
      `initLogos(LogosAPI*)`. Every already-migrated universal module in this
      tree has the same shape.
   3  REAL signature changes, unavoidable — see below.

The four `returnVariant*` methods KEEP their exact `QVariant` return type.
That is why they are declared `nlohmann::json` and not `LogosMap`/`LogosList`:
the parser maps the bare spelling to LIDL `any` (= QVariant), while the aliases
would have narrowed them to QVariantMap/QVariantList. Likewise
`std::vector<uint8_t>` keeps byteArraySize's `QByteArray` parameter and
`std::vector<std::string>` keeps the three `QStringList` slots.

### The three that could not be preserved

  returnJsonArray()               QJsonArray -> QVariantList
  makeJsonArray(QString,QString)  QJsonArray -> QVariantList
  urlToString(QUrl)               QUrl param -> QString param

`QJsonArray` and `QUrl` have no universal spelling: the C++-to-LIDL map has
`[any]` (-> QVariantList) and `tstr` (-> QString) as the nearest types, and
there is no way to ask for the Qt-specific ones from a Qt-free header. The
VALUES on the wire are unchanged — a JSON array is still a JSON array, and the
URL still crosses as its string form — but the declared types move, and
`urlToString` no longer runs QUrl's normalisation (it never had a caller that
exercised it; see below).

This is a deliberate narrowing of Qt-type coverage and should be reviewed as
such rather than waved through. Note the affected assertions:
returnJsonArray / makeJsonArray only assert "Method call successful", and
urlToString has ALWAYS been skipped ("logoscore cannot pass QUrl params"), so
no test observed the QUrl parameter before this commit either.

## Verified

Six checks, built by name through the workspace flake with the local
cpp-sdk / module-builder / plugin-qt / capability-module overrides. All PASS,
with totals identical to the pre-migration run:

  ipc-tests           23 passed   (legacy Qt consumer -> this module, raw
                                   invokeRemoteMethod AND generated wrapper)
  ipc-new-api-tests   23 passed   (universal consumer over lp)
  async-tests          8 passed   (incl. the generated echoAsync wrapper,
                                   which is generated from this module's
                                   contract)
  unit-tests          26 assertions (legacy qt-typed wrappers built from this
                                   module's `headers-qt`, which is now
                                   generated from the derived LIDL instead of
                                   copied from a moc'd plugin — the binding
                                   most at risk)
  unit-tests-new-api  32 assertions (lp-typed, `headers-lp`)
  fullapi-tests        7 passed   (control; does not touch this module)

The module's own assertions live in the `tests` check, which is red at
baseline. Rather than skip them, run_tests.sh was driven directly against the
check's own logoscore and modulesDir, for BOTH the pre-migration and the
post-migration module set:

  TEST_GROUPS=basic   41 passed, 1 failed, 10 skipped   — both runs
  whole suite        174 passed, 2 failed, 25 skipped   — both runs

and `diff` of the per-assertion PASS/FAIL/SKIP lines across all 176 is EMPTY.
The one textual difference anywhere in the two logs is the random temp-dir
path quoted inside a failure message that fails in both.

The two failures are pre-existing, reproduced with the identical command
against the pre-migration modules:
  * `addInts(3.7, 1.2) [double->int rounding]` — the dispatch rejects a
    fractional JSON number for an int param ("expected integer at arg0, got
    number"). Byte-identical message before and after; the legacy Qt module
    did NOT round it either.
  * `getInstancePersistencePath() rooted at temp dir` in
    test_context_module_cpp — an expectation that does not match the path
    layout the host provisions.

`qml-modules` was built BOTH ways and fails identically — same file, same
line 24, same `no member named 'add' in 'TestBasicModule'`
(test-qml-backend-module calls `.add()`; the contract has `addInts`). Not
caused by this commit and not fixed by it.

## NOT verified

* `tests` and `thread-safety-tests` were never built as derivations. `tests`
  is covered above by running its script directly, both ways, which is
  strictly more informative; `thread-safety-tests` uses only the dummy module
  and cannot see this change.
* Linux. aarch64-darwin only.
* The event PAYLOAD of testEvent / multiArgEvent. The names and arity are
  preserved and the emit drivers still return void with the same signatures,
  but the `tests` event-system group only exercises the *_cpp twin's typed
  events; nothing in the suite subscribes to THIS module's events, before or
  after.
* `crashOnDemand` is not exercised here — its consumer is the crash-isolation
  test in logos-logoscore-cli, which was not run.
* The three changed signatures are only argued to be value-preserving on the
  wire; no test compares the actual serialised bytes of returnJsonArray
  before and after.

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

* fix(basic,extlib): answer in CHARACTERS, and normalise the URL

The two fixtures migrated on this branch were counting, reversing and
reporting BYTES. Every assertion that touched those methods was ASCII, where
bytes and characters agree, so nothing in the suite could see it — that
blind spot is the actual defect, and it is closed here first.

Measured on a live logoscore daemon, both module sets built from this tree
(pre = HEAD, post = this commit; `logoscore call`, aarch64-darwin):

  call                                          pre                     post
  stringLength("hello")                         5                       5
  stringLength("héllo")                         6                       5
  stringLength("😀")                            4                       1
  validateInput("héllo").value.length           6                       5
  urlToString("HTTP://Example.COM/a/../b?x=1")  verbatim                http://example.com/a/../b?x=1
  reverseString("hello")                        olleh                   olleh
  reverseString("héllo")                        dispatch_failed:        olléh
                                                invalid UTF-8 byte 0xA9
  reverseString("a😀b")                         dispatch_failed:        b😀a
                                                invalid UTF-8 byte 0x80
  countChar("hello","l")                        2                       2
  countChar("héllo","é")                        1                       1
  countChars("héllo")                           6                       5
  countChar("banana","na")                      2                       2
  countChar("aaa","aa")                         3                       1
  byteArraySize("héllo")                        6                       6

One character = one Unicode code point, everywhere. Neither predecessor
answered that: the Qt plugins answered QString::length() (UTF-16 units — 2
for the emoji), the universal port answered std::string::size() (UTF-8 bytes
— 4 for it). byteArraySize is the one method that still answers in bytes,
because a bstr IS bytes; it now has an assertion saying so, next to
stringLength's, so the two units are contrasted rather than confused.

## Per method

* stringLength / validateInput.length — count the bytes that are not UTF-8
  continuation bytes. One helper, one definition, stated in the header.

* countChar — the needle is a STRING and all of it must match:
  countChar("banana","na") == 2. Matches are counted left to right and do not
  overlap (countChar("aaa","aa") == 1 — it used to say 3, counting the byte
  'a'), and are only accepted where a character starts, so a match can never
  be a fragment of a character. An empty needle matches nothing. All of that
  is documented in the header and asserted in the suite. "é" answered 1
  before too, but only because the FIRST BYTE of "é" (0xC3) happens to occur
  exactly once in "héllo"; the Qt module before it took ch.at(0).toLatin1()
  (0xE9), a byte that occurs in no UTF-8 string, and answered 0.

* countChars — same character definition as stringLength.

* reverseString — reverses by character and the result is well-formed UTF-8.
  Byte reversal was always broken: it splits "é" (C3 A9) into A9 C3.

* urlToString — lowercases the parts that are defined to be case-insensitive
  (scheme, host) and leaves the case-sensitive ones exactly as given, so
  "/a/../b?x=1" comes back untouched. Userinfo is preserved
  ("HTTPS://User:Pw@EXAMPLE.com:8080/Path?Q=A#Frag" ->
  "https://User:Pw@example.com:8080/Path?Q=A#Frag"), a string with no scheme
  is returned unchanged, and an already-lowercase URL is unchanged — which is
  what logos-logoscore-cli's test_integration.cpp asserts for it.

## What still goes through libstrutil

This fixture exists to exercise the external C library, so the calls were
kept wherever the C library can carry the work without producing a wrong or
ill-formed answer. Verified in the shipped dylib, not just in the source —
`nm` shows all six strutil symbols defined in the image and `otool -tvV`
shows the call sites:

  reverseString    strutil_reverse still does the reversal (`bl
                   _strutil_reverse` inside the impl); the module then
                   re-reverses each multi-byte run, which byte reversal
                   necessarily left backwards.
  uppercaseString  entirely strutil (ASCII-only mapping; bytes >= 0x80 pass
  lowercaseString  through untouched, so "héllo" -> "HéLLO" — documented,
                   not fixed: real Unicode casing needs a case table this
                   fixture has no reason to carry).
  countChars       strutil_count_chars still supplies the byte length (`bl
                   _strutil_count_chars`); the module discounts the
                   continuation bytes.
  countChar        strutil_count_char still counts a single ASCII needle
                   (`bl _strutil_count_char`) — an ASCII byte cannot occur
                   inside a multi-byte sequence, so its byte comparison IS a
                   character comparison there.
  libVersion       entirely strutil.

  NOT through the C library any more: the multi-byte / multi-character needle
  path of countChar, and the repair half of reverseString. strutil compares
  and moves single bytes; neither answer can be expressed that way.

## Assertions

13 new rows in the `basic` and `extlib` groups, one per behaviour above plus
the byte/character contrast. Expectations changed (old -> new):

  validateInput(hi)   "Method call successful" -> '"length":2'
    The old expectation asserted only that the call returned. It could not
    tell 5 from 6, which is how the drift shipped. Strengthened, not moved.

No other expected value was changed, and no assertion was weakened: the
per-assertion diff of a full-suite run before and after contains only
additions, the three un-skips below, that one rename, and the random temp-dir
path quoted inside a failure that fails identically both ways.

Three skips were false and are now real assertions:

  urlToString   "logoscore cannot pass QUrl params" — it can, and since the
                migration the parameter is a plain string anyway.
  joinStrings   "logoscore cannot pass QStringList params" — it can, as
                `json:[…]`.
  byteArraySize "logoscore cannot pass QByteArray params" — it can, as bytes.

## Verified

All 9 checks built BY NAME through the workspace flake with the local
cpp-sdk / module-builder / plugin-qt / capability-module overrides, at HEAD
(pristine copy) and at this tree:

  check                pre     post
  async-tests          PASS    PASS
  fullapi-tests        PASS    PASS
  ipc-new-api-tests    PASS    PASS
  ipc-tests            PASS    PASS
  unit-tests           PASS    PASS
  unit-tests-new-api   PASS    PASS
  tests                FAIL    FAIL   174/2/25 -> 187/2/22 (see below)
  qml-modules          FAIL    FAIL   identical error, unrelated
  thread-safety-tests  FAIL    FAIL   byte-identical derivation

`tests` is red on both sides on the same two assertions, neither touched
here and both reproduced against the pre-change modules:
  * addInts(3.7, 1.2) — the dispatch rejects a fractional JSON number for an
    int parameter ("expected integer at arg0, got number"). The expectation
    encodes Qt's old lossy coercion; fixing either side belongs to whoever
    decides that policy, and is not this commit's to decide.
  * getInstancePersistencePath() in test_context_module_cpp — the expectation
    does not match the path layout the host provisions.

`qml-modules` fails on test-qml-backend-module calling `.add()` on a module
whose contract has `addInts` — same file, same line, same message on both
sides. `thread-safety-tests` is the SAME derivation hash on both sides (it
only uses the dummy module) and is killed identically.

## NOT verified

* Linux. aarch64-darwin only.
* uppercaseString / lowercaseString on non-ASCII beyond "bytes pass through":
  that pass-through holds in the C and UTF-8 locales, and a single-byte
  toupper() in a Latin-1 locale could still corrupt a multi-byte character.
  Not asserted, because the assertion would be locale-dependent.
* Grapheme clusters. A combining sequence is N code points here, by the
  stated definition, and reversing it separates the marks from their base.
* No test compares serialised bytes before/after for the methods this commit
  does not change.

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

* fix(extlib): make case mapping locale-independent, and correct a false claim

The header, README and commit message all claimed uppercaseString/lowercaseString
pass bytes >= 0x80 through untouched. An adversarial review measured that this
was FALSE in the locale the module actually runs in:

  lowercaseString("HÉLLO") -> dispatch_failed: invalid UTF-8 byte at index 3
  480 of 503 non-ASCII code points returned invalid UTF-8 from lowercaseString,
  23 of 503 from uppercaseString (every 3- and 4-byte character), and 7 more
  came back as a silently DIFFERENT character (µ->U+009C, ε->Μ, Cyrillic е->М).

Cause: strutil used toupper()/tolower(), which are LOCALE-DEPENDENT. In the C
locale the pass-through claim holds; in en_US.UTF-8 they map ~30 of the 128 high
bytes, including the UTF-8 lead bytes 0xC3/0xE4/0xF0 — so they destroy 2-, 3-
and 4-byte characters. The shipping locale is the corrupting one. "héllo" ->
"HéLLO" was a cherry-picked survivor: 0xC3/0xA9 have no UPPERcase mapping, which
is why the mirror-image lowercase call was the one that failed.

That is the same dispatch_failed/invalid-UTF-8 failure the previous commit was
written to eliminate, still live in two of six methods here and documented as
safe.

strutil now compares 'a'..'z' / 'A'..'Z' explicitly, so the pass-through is real
in every locale. The case mapping stays ASCII-only and stays inside the C
library, which is what this fixture exists to exercise. Three assertions added
for the non-ASCII round-trips — the previous reason for not asserting them
(locale-dependence) was backwards: the CALL FAILING is perfectly stable and
assertable.

VERIFIED: ipc-tests PASS with this in place.
NOT VERIFIED BY ME: a live-daemon measurement of the two case methods. My ad-hoc
daemon failed even on pure-ASCII uppercaseString("hello"), so the harness was
wrong and I did not trust any reading from it. The three new assertions live in
run_tests.sh, whose `tests` check is red for two unrelated pre-existing reasons,
so they have not been observed passing either. Someone should run them.

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

* fix(basic-cpp): answer stringLength in characters, mirroring test_basic_module

test_basic_module_cpp is the pure-C++ mirror of test_basic_module, but after the
universal migration the two disagreed on non-ASCII: the Qt-derived one counted
CHARACTERS while this one still counted BYTES (s.size()), so
stringLength("héllo") was 5 there and 6 here, and validateInput's length field
likewise. Same for the code-point definition of "character": one emoji is 1.

The blind spot is the point: this module's group only ever asserted ASCII, where
bytes and characters coincide, so two modules that exist to mirror each other
could drift apart invisibly. Two non-ASCII assertions added — the same pair the
sibling carries, so a future divergence fails on both sides.

The helper is copied rather than shared: these fixtures are deliberately
standalone (this one's whole point is that it compiles with no Qt anywhere), and
a shared header between them would weaken that. The comment on each says so.

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

* feat: link the unit tests against logos-qt-host, not logos-qt-sdk

The Qt host runtime the two IPC unit-test binaries link — LogosAPI,
LogosAPIProvider, the provider base classes and the legacy QMetaObject
adapter — now lives in logos-plugin-qt and is exported as
logos-qt-host::logos_qt_host. Point both tests/CMakeLists.txt at that
target and prefix, and give the flake its own logos-plugin-qt input.

logos-qt-sdk is dropped entirely from this repo: the only thing it ever
supplied here was the host runtime. Nothing consumes the qt consumer
emitter or the LpBridge headers from it — the pinned qt-sdk's
include/cpp does not even carry logos_qt_lp_bridge.h.

Two details worth keeping:

  * logos-plugin-qt's logos-protocol follows logos-liblogos', so the
    logos_qt_host archive and the protocol the test binary links are ONE
    build. logos_qt_host is static and its exported target carries
    logos-protocol::logos_protocol, so two protocol store paths would put
    two protocol archives on a single link line.

  * The source layout gains logos_qt_arg_decode.cpp, listed
    unconditionally rather than behind an EXISTS guard:
    qt_provider_object.cpp routes every incoming argument through
    logos::qtArgDecode, and a guard would turn a missing checkout file
    into a late undefined symbol instead of a configure error.

find_package(logos-qt-host) stays REQUIRED and the target reference stays
unguarded, so a rename can only produce a hard configure failure.

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

* chore(deps): rev-pin the B4 stack, and keep the qt seam headers reachable

Every input this repo reaches through now names an explicit rev, because none
of the four branches involved has landed on its master and a plain
`github:logos-co/<repo>` url lets `nix flake update` silently walk each one
back to a master that cannot satisfy this flake:

  logos-module-builder  c60d4a9  feat/sdk-codegen-b4-qt-host-repoint
  logos-liblogos        f2a15ef  fix/b4-align-protocol-with-qt-host
  logos-logoscore-cli   24ad063  feat/sdk-codegen-b4-qt-host
  logos-plugin-qt       cc24fa1  feat/b4-qt-host-windows-target

cc24fa1 is the SUPERSET sibling of feat/b4-qt-host-windows-target-8ccb1fc and
the exact rev logos-module-builder pins for BOTH logos-plugin-qt and
logos-plugin-core. With this lock, `logos-qt-host` evaluates to ONE derivation
(1xbf6fy9…-logos-qt-host-0.1.0.drv) whether you reach it through this flake's
logos-plugin-qt or through logos-module-builder's — the unit-test binaries and
the module plugins link the same runtime, not two copies of it.

Consequences of landing this repoint on top of master:

  * master moved the SDK inputs (logos-cpp-sdk, logos-protocol) from
    logos-liblogos.inputs to logos-module-builder.inputs so the generator and
    the headers it emits move together. logos-plugin-qt's logos-protocol
    follows the BUILDER's now rather than liblogos's — same invariant, new
    address. logos_qt_host is a static archive whose exported target carries
    logos-protocol::logos_protocol, so two protocol store paths would put two
    protocol archives on one link line. Verified: both resolve to
    yq3q02yp…-logos-protocol.drv.

  * logos-qt-sdk no longer supplies the host runtime, but it does NOT go away.
    It owns the Qt<->lp seam headers (logos_qt_lp_bridge.h, logos_qt_wire.h),
    and the builder at c60d4a9 emits the Qt-typed dependency wrapper as a
    VENEER over the lp path: the `headers-qt` output the LEGACY unit tests
    compile now opens with `#include "logos_qt_lp_bridge.h"`. Dropping the
    input outright — which is what "nothing here needs logos-qt-sdk" implied
    when this branch was written against liblogos's older pin — made
    `.#checks.<sys>.unit-tests` fail with a `file not found` inside generated
    code. So logos-qt-sdk is back, headers-only: no archive is linked, no
    source is compiled from it, and only the `unit-tests` derivation gets it.
    `unit-tests-new-api` consumes `headers-lp`, whose wrapper has no such
    include, and is deliberately left alone.

logos-nix moves e637a1f -> 6e0f4a7 as well, and it is not cosmetic: cc24fa1's
flake calls `logos-nix.lib.forAllTargets`, which e637a1f does not export, so
with `logos-plugin-qt.inputs.logos-nix.follows = "logos-nix"` in place every
check failed to EVALUATE ("attribute 'forAllTargets' missing"). 6e0f4a7 is the
rev logos-module-builder, logos-liblogos and logos-plugin-qt itself all lock,
and it pins the same nixpkgs (e9f00bd) e637a1f did — so no Qt moves.

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

* refactor(qtproxy): a cdylib provider with Qt-typed consumers

Retires the LAST caller of --provider-header, a generator mode D11 removed.
This module invoked it by hand from a preConfigure hook and had NO `interface`
key, which is exactly why module-builder's guard never caught it — the guard
reads a field this module did not set.

It could not simply become `interface: "universal"`: it is the conformance
matrix's THIRD consumer surface (qtproxy-sync / qtproxy-async), the only
subject measuring the QT-TYPED consumer path. known.json records that the
C++/Rust proxies cannot substitute — both are lp-client consumers and share
py's blind spot — and that this surface is where Q1 was found. Making it
universal used to flip its wrappers to lp and delete that coverage.

It is now expressible: `interface: "universal"` + `codegen.consumer_api_style:
"qt"` — a cdylib provider (std logos_module_impl.h C ABI) whose CONSUMER
wrappers are Qt-typed, binding through LpBridge::forOrigin with this module's
own name as origin and no LogosAPI anywhere.

  47 methods -> 45. The two removed are useWrapper/currentWrapper, which
  existed to switch every forwarded call between the generated wrapper and a
  hand-written veneer. Since B5 the Qt wrapper IS the veneer — both run over
  lp_* — so that axis measured one thing twice. Retired deliberately, recorded
  in known.json with the reason, not dropped. The FullApiVeneer went with it;
  it was the only reason this module needed a raw LogosAPI*.

  0 events -> 15. Emission moves from a dynamic emitEvent() on the Qt provider
  object to a declared logos_events: block serialised through nlohmann into the
  cdylib event bridge. That bridge has silently lost uint64 in this tree
  before, so it was measured rather than assumed, at the values that would
  expose it:

      fireUintEvent(18446744073709551615) -> uintEvent:18446744073709551615
      fireIntEvent(-9007199254740993)     -> intEvent:-9007199254740993

  Exact both ways, through the repo's own fire/getLastEvent round-trip idiom.

Verified: builds standalone with NO overrides; the plugin defines
logos_module_accept_token (the property the origin-binding gate keys on, i.e.
this image fills its own TokenManager by the C-ABI route rather than needing
LpBridge::syncTokens); fullapi-tests 7/7; lm methods diff accounts for every
difference; the emitted wrapper contains zero LogosAPI.

check_contract_copies.py had to change too — it parsed this header as kind
`cpp-qt`, and that machinery is now unreachable.

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

* test(ipc-new-api): cover async consumption from a Qt-free module

test_ipc_module was the only module asserting async inter-module calls, and it
is a Qt consumer — so migrating it off the legacy interface would have deleted
the repo's only async coverage. Its universal successor covered zero async.
This closes that hole, which is the precondition for retiring the Qt-consumer
original rather than a change to it.

Four methods mirroring the originals by name. On the Qt consumer three were RAW
invokeRemoteMethodAsync and one went through a generated wrapper; here all four
go through wrappers, because on the lp surface `<name>Async` IS the generated
wrapper and bottoms out in lp_invoke_async. So these assert something the Qt
ones cannot: async delivery into a module with no Qt in its own TUs.

WHY concurrency: "multi" IS REQUIRED, and is not incidental here. A Qt-affine
client is bound to the Qt main thread, and its completions are marshalled back
to that thread. Under `single`, dispatch runs on the main thread too — so a
method that blocks waiting for its own completion is blocking the exact thread
that has to deliver it, and every call burns its full timeout and returns a
default-constructed value. That is logos_thread_marshal.h's documented hazard
("only pumps events while it happens to be blocked inside a call") reached from
the other side. The Qt consumer never hit it because QEventLoop PUMPS while it
waits; std::future::wait_for does not. Under `multi` the handler runs on a
worker QThread and the main thread stays free to marshal.

Measured, not assumed: under `single` seven of the eight assertions failed with
empty/zero results. Note the eighth, asyncCallBasicAddInts(0, 0), PASSED —
the timeout sentinel is 0, which is also the expected answer. It is kept
deliberately, next to (3, 4), so the pair cannot both go green on a stall.

The promise is held by shared_ptr rather than by reference into the frame: on
the timeout path the frame is gone while the call is still outstanding, and a
late completion would otherwise write through a dangling reference — corrupting
memory precisely when something has already gone wrong. The wait is bounded so
a stall names the method that stalled instead of surfacing as the harness's own
timeout.

Requires the qt-host-generator capture-list fix: this module has a void method
and no result method, the combination that did not compile under multi.

Verified: 31 passed, 0 failed, 1 skipped.

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

* test: retire test_ipc_module, now fully duplicated

It was the last `interface: "legacy"` provider in the repo, and it no longer
covers anything of its own. Its 21-method surface is mirrored 1:1 by
test_ipc_new_api_module, and the Qt-typed consumer style it exercised now has a
dedicated home in test_fullapi_qtproxy (`consumer_api_style: "qt"`).

Removing it is what makes an absent `interface` mean "not a provider" rather
than "a provider whose glue silently went missing" — see the throw added to
logos-module-builder alongside this.

Groups `ipc` and `async` go with it, and so does the `unit` group, whose binary
came from the deleted derivation and would otherwise have gone permanently SKIP.
The `multi` group is repointed at the successor rather than deleted: it tests
sequential -c chaining, not the module, and the successor answers the same three
calls.

The async group is NOT simply gone. Its assertions were ported to the
ipc-new-api group in bc942e2 first, and they assert more there — on the lp
surface `<name>Async` IS the generated wrapper, so they cover async into a
module with no Qt in its own TUs. That ordering was the point: the coverage
moved before the module was removed, never the other way round.

Verified with the full suite: 172 passed, 0 failed.

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

* fix(fullapi-ext): echoOptional returns `result`, not `?tstr`

test_fullapi_ext_rust could not emit its Qt consumer headers at all:
`logos-qt-generator --backend consumer` refuses `-> ?T`, so
`.#logos-test-modules--test_fullapi_ext_rust` had no output. The Rust crate
compiled fine, which is why this went unnoticed.

The generator is right and this contract was wrong. Its own commentary claimed a
`?T` return is spelled null on the wire "and the arity never changes" — true for
an ARGUMENT, false for a RETURN, because null is ALREADY how a failed call is
reported on that path: logos_json_convert maps it to an invalid QVariant, which
core_service turns into METHOD_FAILED. An empty `?T` and a failure would be the
same wire value for every non-Rust caller. Rust never noticed, because
Option<String> carries no failure channel to collide with.

`result` is the honest spelling and is strictly more expressive here: success
with a null value means "found nothing", success = false means the call failed.
The two states that were colliding are now distinguishable.

The `?tstr` PARAMETER stays, which is where the both-spellings guard above it
actually bites — that guard is about `? maybe: tstr` and `?tstr` emitting
byte-identical code, and it was written after they disagreed on the ARGUMENT
side. Nothing about it needed the return type.

The contract's commentary is corrected rather than deleted, since the claim it
made is the interesting part of the bug.

Verified: `.#logos-test-modules--test_fullapi_ext_rust` now builds to a real
store path.

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

* conformance: collapse the container-leniency divergence, retire Q1 and Q1b

Registry only; the code change it follows is logos-cpp-sdk 853a261 /
logos-protocol 0af8e0c, which made the cdylib container decode shape-check
`[any]` and `{tstr:any}`.

cases.json — hostile/[any]/scalar and hostile/{tstr:any}/scalar lose
expect_by_provider (test_fullapi_cpp: "notalist" / 5) for a single
expect = {"__error__":"dispatch_failed"}. Both providers refuse now, so the
split has nothing left to record. Each case's `why` keeps its history and gains
the new measurement rather than being rewritten — the earlier readings are the
record of how the cause moved.

known.json — Q1b and Q1 retired, into the `retired` list that already held
qtproxy-wrapper-axis.

  Q1b registered exactly the divergence above. Its own `fix_is` named this change
  and declined it: "make the C++ cdylib provider's container decode refuse a
  non-array/non-object, collapsing the expect_by_provider. Still valid on its own
  merits, but it is no longer motivated by this cell." It was motivated by a
  different cell in the end — the same leniency was corrupting values through the
  Qt consumer, turning "notalist" into an 8-element list.

  Q1 was stale independently of any of this. All 12 of its cells PASS, so it
  reported xpass and kept the run red. Three arms show the same 12 xpass —
  including the UNPATCHED build with the old registry — so it did not go stale
  here. It went stale when test_fullapi_qtproxy became `interface: "universal"`:
  Q1 describes a Qt-typed PROVIDER dispatch that reads a declared type from C++,
  where `[uint]` and `[any]` are both QVariantList so only array-ness is
  checkable. That dispatch is gone from the fixture; inbound arguments take the
  cdylib decode, which has the element type and refuses a bad element.

  Q1's own prediction said the opposite — "expected to leave the three registered
  cells where they are". It is preserved in the retirement record rather than
  dropped: its reasoning (the fixture cannot carry a rejection back through a
  bare list return) is still true; what it missed is that the rejection now
  happens at the inbound decode, before the return type matters at all.

MEASURED, three arms, same vehicle each time:
    unpatched + old registry   498 pass / 14 xfail / 12 xpass /  4 fail   exit 1
    patched   + old registry   500 /  14 / 12 /  2                        exit 1
    patched   + new registry   506 /  10 / 12 /  0                        exit 1
    + Q1 retired               518 /  10 /  0 /  0                        EXIT 0

Each step's cell-level diff is exactly the cells it should be — 6, 6, then 12 —
with ZERO changes to any measured value and no rows added or removed, across all
532. Nothing about behaviour moved at the last step; only the registry's
classification of it.

Note on the counts: exit 0 needed the xpass to go too. run_matrix.py counts xpass
as a failure alongside fail, so `fail == 0` at 506/10/12/0 was still a red run —
a registered divergence that has silently stopped diverging is as much a defect
in the instrument as an unregistered one.

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

* ci: use logos-co/setup-nix-cache-action for Nix setup and caching

Replaces the per-repo installer + cachix pair with the shared action, which
installs Nix with the Logos Attic cache (cache.nix.logos.co) preconfigured and
publishes what the job builds — master to the public cache, every other ref to
ci.

Each converted job also gains

    environment: ${{ github.ref == 'refs/heads/master' && 'public-cache' || '' }}

because ATTIC_TOKEN_PUBLIC only exists inside that environment. Without it the
secret resolves empty on master and publishing is silently skipped — the job
still passes, so the omission would not show up as a failure.

The action installs Nix itself on every runner, macOS included. That is a
deliberate reversal of the workaround these files carried: the comments here
said cachix/install-nix-action collides with the runner's pre-existing _nixbld
users (eDSRecordAlreadyExists), so DeterminateSystems' installer was used
instead. It no longer reproduces — logos-delivery-module has already been
converted the plain way and its `build-and-test (macos-latest)` leg passes.
Keeping the workaround would have meant a second installer plus a duplicated
substituter/key block in ten files, guarding against something two green runs
say does not happen. If it ever recurs it fails loudly at install, which is
recoverable; the silent-skip above is the failure mode worth engineering
against.

One property is deliberately NOT carried over: the old cachix step ran with
`continue-on-error: true` so a failed cache push could not fail a job whose
tests passed. The action exposes no equivalent, and adding one here would also
swallow genuine setup failures now that the same step installs Nix rather than
only publishing at the end.

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

* docs: describe test modules by their real interface

Comments and module descriptions still advertised "the new provider API
(LogosProviderBase)" for modules that are now `interface: "universal"`, and
flake.nix described a one-step --from-header codegen that is actually three
steps. The conformance xfail reason for Qt records is recast as history now
that --backend qt is gone from logos-qt-generator.

Module `description` metadata changes with it; nothing pins those strings.

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

* chore(deps): retire the four B4 rev pins, and fix the CI job they outlived

Every input this flake rev-pinned onto a B3/B4 feature branch has landed on
its master, so all four urls go back to tracking the default branch. Relocked
with an explicit `nix flake lock --update-input <name>` per input — a bare
`nix flake lock` does NOT re-resolve an already-locked input after its url
stops carrying a rev, which would have left the flake reading "master" and the
lock reading the old feature-branch commit.

  logos-module-builder  5081088 -> 8cd62c7  (module-builder#203: master now
      carries ZERO rev pins and locks logos-cpp-sdk, logos-protocol,
      logos-qt-sdk, logos-plugin-qt and logos-plugin-core at their masters)
  logos-liblogos        f2a15ef -> 93207e4  (liblogos#177, "track protocol and
      plugin-qt master")
  logos-logoscore-cli   24ad063 -> b31bc8f
  logos-plugin-qt       2d25069 -> 9b2c64e5 (plugin-qt#19: master exports
      packages.<sys>.logos-qt-host and takes a logos-protocol input — the two
      things whose absence the pin existed to avoid)

The load-bearing invariant is preserved and was checked in the new lock, not
assumed: logos-plugin-qt/logos-protocol and logos-module-builder/logos-protocol
resolve to the SAME node (logos-protocol_263, f4407ff4). logos_qt_host is a
static archive whose exported target carries logos-protocol::logos_protocol, so
two protocol store paths would put two protocol archives on one link line. The
`follows` stays even though both sides now track master — master-vs-master is a
coincidence that lasts until one of the two locks is refreshed alone.

Locked SDK revs after the relock, all equal to their masters:
  logos-cpp-sdk 95d7b3a9, logos-qt-sdk 19c844f2, logos-plugin-qt 9b2c64e5,
  logos-protocol f4407ff4.

Two things the pin retirement exposed, fixed here rather than left:

  * .github/workflows/ci.yml still ran `.#checks.x86_64-linux.unit-tests`. That
    check went with test_ipc_module in af567c1 and the README on this branch
    already says so ("`.#checks.<system>.unit-tests` no longer exists"). The job
    would have failed to EVALUATE on every push. Repointed at
    `unit-tests-new-api`, the surviving mock-based check.

  * the `logosQtSdkPkg` let-binding had no remaining reader — its only consumer
    was that same deleted `unit-tests` derivation — and its comment asserted
    otherwise. Removed, with a note saying why nothing here needs logos-qt-sdk
    (module PLUGIN builds still reach it through logos-module-builder).

Verified on aarch64-darwin, real store paths, not exit codes:
  unit-tests-new-api  PASS  32 passed, 0 failed
  ipc-new-api-tests   PASS  31 passed, 0 failed, 1 skipped
  fullapi-tests       PASS  7 passed, 0 failed
  conformance/check_contract_copies.py  exit 0 (8 copies agree, 33 methods,
                                         15 events)

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:17:16 -03:00
Dario LipicarandClaude Opus 5 209594df46 feat(conformance): a Qt-typed consumer surface, and the A/B that measures it (#34)
* probe(qt-consumer): throwaway module proving interface_dependencies works on a Qt api-style module

test-qtbind-probe-module is type: core with NO `interface` key, so the
backend picks apiStyle=qt. It declares interface_dependencies on full_api
(a .lidl copy of the shared contract) plus concrete dependencies on both
providers, forwards echoInt, and re-emits intEvent.

Phase-1 throwaway: delete once the real Qt-typed proxy lands.

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

* feat(conformance): a QT-TYPED consumer of full_api — the matrix's third surface

The matrix had two providers and one consumer coordinate. The existing proxies
do not add another: `interface: "universal"` selects apiStyle=lp and
`interface: "cdylib"` selects the Rust client, so both bypass the Qt generated
wrappers. Replaying the whole case table through them moved 2 cells of 86.

test_fullapi_qtproxy is `type: core` with NO `interface` key, which selects
apiStyle=qt (mkLogosModule.nix picks lp only for universal non-ui_qml), so
`modules().bind_full_api(name)` hands back a Qt-typed wrapper — QString /
QByteArray / qlonglong / qulonglong / QVariantList / QVariantMap / LogosResult.
It forwards all 33 full_api methods and re-emits all 15 events, so the matrix
replays cases.json through it unchanged.

Two things only this surface reaches, both now measured end-to-end under
logoscore against BOTH providers:

  * M3. A one-key `_bytes` map is reinterpreted as bytes in
    logos_json_convert.cpp on the way to a Qt consumer. At a `{tstr: any}` slot
    that is total loss: `echoMap {"_bytes":"AID_"}` answers `{}`, where the same
    call at the provider round-trips and looks green.
  * The generated ASYNC return table converts with `qvariant_cast<T>(v)` where
    the SYNC one uses `_result.toT()`. syncProbe() and probeAsync() /
    getAsyncProbe() render the same 18 calls through both tables in one format
    so they are diffable; today they agree, and now a change to either is
    visible instead of silent.

Also:
  * check_contract_copies.py grows from 5 to 8 tracked copies and learns a third
    parse kind. The Qt copy is compared by COMPATIBILITY, not equality, because
    the Qt style is not 1:1 with LIDL — `QVariantList` is the one type behind
    `[any]`, `[int]`, `[uint]`, `[float64]` and `[bool]`. Verified live against
    four injected drifts (missing method, wrong type, wrong arity, unknown
    spelling); a Qt provider has no declared event block, so its events stay
    compiler-enforced rather than silently "checked".
  * Subscriptions are per-provider-once and drop deliveries from a provider that
    is no longer bound. Without this, re-binding stacked callbacks and every
    event arrived three times — measured, and it would have multiplied every
    event-position cell.
  * Drops test-qtbind-probe-module, the phase-1 throwaway; every finding it
    established is reproduced by this module. It stays in history at 432812a.

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

* feat(conformance): sync/async as an axis, and what the Qt consumer actually changes

The Qt proxy forwarded every method through the SYNC generated wrapper only.
That left the async table — which converts with `qvariant_cast<T>(v)` and
substitutes a default on an invalid QVariant, where sync uses `_result.toT()` —
driven by nothing but an 18-call fixed probe.

`useCallMode sync|async` makes it an axis: all 33 forwarded methods route
through whichever table is selected, so the whole case table replays twice.
`lastCallStatus()` reports `ok-sync`/`ok-async` per call, because a mode switch
that silently no-ops would make the async half a duplicate of the sync half and
86 green cells would prove nothing. Async waits by polling a mutex-guarded slot
and pumping, never a cross-thread QEventLoop::quit(); a completion is delivered
on whatever thread the transport uses. Events keep no mode — a subscription is a
callback either way, and the header says so rather than leaving it a gap.

Measured over both providers:

  * sync vs async: 0 deltas. The difference is real in the generated source and
    is now driven; it does not currently change an answer.
  * M3 is MEASURABLE and moves from `unmeasurable` to an ordinary xfail. The
    collision is symmetric, so an `any` echo is green no matter what happens in
    between — the observation is a TYPED slot: echoMap({"_bytes":"aGk"}) arrives
    {}. Confirmed head-on by the proxy's type-tagged event rendering, which says
    QByteArray where the sender put a map.
  * Q1 is new and was not suspected: the generated Qt dispatch coerces a hostile
    argument into the declared parameter type before the method body runs, so
    echoUint(-1) answers 18446744073709551615 where every other surface answers
    dispatch_failed. Nine cases. Argument validation is not something a Qt-typed
    module can rely on.

Both attributed by replaying the same table through the lp proxy — a hop of the
same shape on a non-Qt client — which rejects all nine and preserves the map. So
it is the api style, not the extra hop.

Registry: `consumers` is now REQUIRED on every xfail entry, with no default. An
entry that does not say which surface it was measured on manufactures failures
on the surfaces it was not, and that already happened once on the provider axis.
M4-residual gains its two Qt consumers only after measuring them.

`skip[]` was declared and never read. Reading it found its own first bug: the
pattern `uint/boundary` matched nothing, because the case had been split in two
and the entry was never updated. A registry that is not executed decays into a
comment.

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

* spike(qtproxy): the Qt full_api surface as a veneer over the lp wrapper

Phase-1 spike for "make the Qt consumer a skin over the lp one". Adds, beside
the generated Qt `FullApi`, a `FullApiVeneer` with the SAME 33 sync + 33 async +
15 typed-event surface whose every body is convert-args / delegate / convert-
return against `FullApiLp` (verbatim `--api-style lp` generator output, class
renamed). `useWrapper("generated"|"veneer")` routes the whole forwarded case
table through either one against the same provider in the same process, so the
two are diffed rather than reasoned about.

The constraint under test is that the veneer writes NO conversion of its own.
It does not: the entire adapter is four function templates over
logos::qvariantToNlohmann / nlohmannToQVariant (logos-protocol
logos_json_convert.cpp:38,129) and logos::toJson / fromJson (logos_codec.h),
plus two specialisations for `result` — the one LIDL type whose canonical
converters do not exist and would have to be added to logos-protocol
(json -> LogosResult) and logos-cpp-sdk (StdLogosResult -> json).

Measured, both providers, sync and async: identical on every cell except
`result`, where the std hop turns a null error into "". `makeResultNoStdHop`
renders the same call without the std intermediate and matches the generated
wrapper exactly, so the divergence is the hop, not the veneer.

`tokenProbe` records the prerequisite the spike hit first: a Qt-style plugin has
TWO TokenManager singletons (qtTM=100889ec0 lpTM=102fb58a0 same=NO qtCap=yes
lpCap=no), because LogosAPI is constructed in the host image while
lp_client_create is compiled into the plugin. Every lp call from a Qt plugin is
therefore unauthorized and returns a default value. The cdylib backend seeds its
copy via logos_module_accept_token -> lp_token_save; the Qt backend has no such
hook. veneerTarget() does that seeding inline so the type matrix could run.

Spike only — not a proposal to merge.

* test(qtproxy): A/B the two implementations of one Qt surface, in one process

The hand-written spike veneer is replaced by verbatim
`logos-qt-generator --backend consumer` output (class FullApiVeneer, bound
mode). `useWrapper` still swaps every forwarded call between the legacy
generated Qt wrapper and the veneer, against the same provider in the same
process, so the two are diffed rather than reasoned about.

Result: identical on every cell — 40 method/probe cells and 15 events, sync and
async, against both test_fullapi_cpp and test_fullapi_rust. Including uint64
max, int64 min, an int outside double's exact range, bytes carrying
0x00/0x80/0xFF, and `any` as string/number/object.

The one cell that diverged in the spike is closed. `makeResult(true)` rendered
`e=s:` through a veneer that hopped via StdLogosResult (whose error is a
std::string and so cannot be absent) and `e=-` through the generated wrapper.
This wrapper converts Qt <-> canonical JSON and calls the lp client directly —
no std intermediate — and now renders `e=-` both ways.

Everything the spike did by hand in veneerTarget() (owning the LpClient and its
subscriptions per provider, seeding the plugin-side TokenManager) is gone: it
lives in logos-qt-sdk's LpBridge, which is where generated code can reach it.
The veneer is now constructed exactly like target() — same two arguments.

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

* test(qtproxy): the same consumer module, built against each Qt implementation

Phase 2 A/B'd the two Qt consumer implementations by compiling BOTH into the
module: the build's generated `FullApi` plus a committed copy of the veneer
emitter's output under a second class name. That proves the two agree; it does
not prove the veneer is reachable through the BUILD, and every call site had to
be made generic over the wrapper type to switch between them.

`mkQtProxy` / `mkFullapiUi` take `qtConsumerCodegen`, so each module is built
TWICE from one `src`, differing only in which generator emitted
full_api_api.{h,cpp}:

    legacy   logos-cpp-generator --general-only --api-style qt
    veneer   logos-qt-generator --backend consumer   (over the legacy output)

Not one line under src/ differs between the two builds — the premise of the whole
change is that a Qt call site cannot tell. The generated trees differ in exactly
the three full_api_api files and nothing else, and both plugins link. (The
phase-2 in-process copy stays for now as a control: `useWrapper veneer` still
reaches it, and it agrees with the build's wrapper in the veneer build.)

Measured under logoscore against both providers, every call routed through
`modules().bind_full_api(provider)`:

  * 212 cells (2 providers x sync/async x 53 type cells incl. every event)
    identical between the two builds.
  * 36 failure-path cells (bound to a module that does not implement the
    contract, so the invalid-QVariant branch runs) identical between the two
    builds AND between sync and async in each.
  * Against a DIRECT call on the provider, 35 of 36 cells agree in both builds.
    The one that does not is registry entry M3 and it is unchanged by this work:
    `echoMap({"_bytes":"aGk"})` is `{}` through a Qt consumer either way.

The runtime proof that `bind_full_api` really is the lp path in the veneer build
is the token probe. After one warm-up call:

    legacy  qtCap=yes lpCap=no  qtProv=yes    Qt client minted a target token
    veneer  qtCap=yes lpCap=yes qtProv=no     LpBridge mirrored the capability
                                              token; no Qt target token exists

test_fullapi_ui gets the same treatment: a second, independently written Qt
consumer, binding a `.h` interface rather than a .lidl contract. Build only — it
is a UI plugin and needs the app to drive.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:09:59 -03:00