Commit Graph
6 Commits
Author SHA1 Message Date
Dario Gabriel LipicarandClaude Opus 5 f03a5d8bf6 fix(ext): the two providers agree on echoOptional again, at -> ?tstr
One contract had two providers answering different SHAPES.
test_fullapi_ext_rust.lidl declared `echoOptional(v: ?tstr) -> result`;
test_fullapi_ext_cpp is header-first and kept deriving `-> ?tstr` from
`std::optional<std::string> echoOptional(...)`. Carried as known-ext.json's
`ext-optional-return-changed-on-one-provider`, and visible on a second surface
as `ext-optional-return-shape-through-the-qt-proxy`.

THE WORKAROUND OUTLIVED ITS CAUSE. `-> result` was chosen because
`logos-qt-generator --backend consumer` REFUSED `-> ?T` -- null was also how a
failed call reported itself on that path, so an empty ?T and a failure were one
wire value. That gate is retired: `lidlTypeToQt` maps `?T` to
std::optional<T>, so the empty answer is std::nullopt, an inhabitant of its
own, and failure travels on logos::CallError, decided by the C ABI return code
and never by the value's null-ness.

So `-> ?tstr` is emittable today, it is what the C++ provider already derives,
and it is what the case table already expects. Reverting the Rust side is what
makes the two agree; the alternative (moving C++ to `-> result`) would have
meant changing the expectations to match a workaround for a constraint that no
longer exists.

Changed together, because a proxy that forwards a shape must declare the same
one: the Rust LIDL and its impl (`Option<String>` in and out, no success flag
standing in for presence), the qtproxy's own interfaces/full_api_ext.lidl, and
the qtproxy impl's echoOptional -- which now forwards through sOptStr rather
than sResult, and renders with rOpt rather than rResult in the exercise-all and
async blocks.

Both registry entries are retired. The proxy one had a SECOND, independent half
-- a Qt wrapper that could not decode a reply returned a default-constructed
LogosResult, and that default is byte-for-byte a provider refusal -- which is
fixed in logos-qt-sdk#44 and logos-cpp-sdk#150 and stands whichever shape the
contract had chosen. Recorded in the `fixed` entry, because retiring it here
without naming that half would credit the shape change with more than it did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 14:33:47 -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 f8077fab02 fix: the record event emitter is typed now — pass the Blob, not its JSON (#43)
* fix: the record event emitter is typed now — pass the Blob, not its JSON

rust-sdk#38 made typed event emitters record-aware, so emit_blob_event takes
`&Blob` instead of `&serde_json::Value`. This module's call site passed
`&v.to_json()` and no longer compiles.

Its own comment described the old signature as a workaround — "the emitter takes
raw JSON even for a record parameter, so the record is encoded here through its
own to_json" — which is exactly what #38 removes. The comment now says what is
actually true: the generator calls to_json on the author's behalf, so an event
payload cannot drift from the one a record RETURN produces.

THE WIRE IS UNCHANGED. `v.to_json()` is the encoder the generator now calls, so
the payload is byte-identical — including the tagged bstr field the old comment
was protecting. The conformance case `event/Record`
(conformance/ext-cases.json) pins that payload and is untouched:

    {"id": "e", "n": 7, "payload": {"_bytes": "aGk"}}

Atomically coupled to the builder bump: this module cannot compile against both
the old and the new emitter signature, so the flake.lock bump and the source fix
have to land together.

!! flake.nix currently pins logos-module-builder to the BRANCH commit of
!! logos-module-builder#188 so this is buildable before that merges. Revert to
!! `github:logos-co/logos-module-builder` and re-lock to merged master before
!! landing.

Verified: `nix build .#test_fullapi_ext_rust` succeeds.

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

* chore: re-pin logos-module-builder to merged master

Reverts the temporary branch pin now that logos-module-builder#188 has landed as
ddddd8c. The lock's narHash is unchanged from the branch commit, so the squashed
master commit is byte-identical content — the build this PR was verified against
is the build it now gets.

Verified: nix build .#test_fullapi_ext_rust resolves to the same store path as
before the re-pin.

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

* fix: take the SDK triple from module-builder, not from liblogos

The unit tests compiled code the BUILDER's generator emitted against headers
reached through a SIBLING's inputs:

    logosSdkPkg = logos-liblogos.inputs.logos-cpp-sdk.packages.${system}.default;

logos-liblogos and logos-module-builder pin logos-cpp-sdk independently, on
different schedules, so the two drifted:

    module-builder  cpp-sdk dfd4628   generator emits #include "logos_async_result.h"
    liblogos        cpp-sdk 5f63af6   header does not exist yet

The moment logos-cpp-sdk#132 taught the generator to emit that include, the unit
tests stopped compiling with

    generated_code/test_basic_module_api.h:14:10:
      fatal error: 'logos_async_result.h' file not found

Neither repo was wrong on its own — the generator shipped the header it needs,
and liblogos was simply pinned elsewhere. The bug is asking a sibling for the
SDK that a DIFFERENT repo's generator determines.

Now sourced from logos-module-builder, so generator and headers move together by
construction.

Not caused by the emitter change in this PR, and not new: module-builder 97ffa59
(#186, before any of this work) fails identically, and master only passes because
it is pinned five builder releases back — to a cpp-sdk that predates #132, where
the generator does not emit the include at all.

Verified: `nix build .#checks.<system>.unit-tests` fails on this branch before
this commit with the error above, and succeeds after.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 20:34:44 -03:00
Dario LipicarandClaude Opus 5 fbd9d161b3 conformance: sweep the prose two landings left behind (#42)
An audit of every factual claim in the conformance corpus against a fresh
green run of both flake checks. 29 stale sites, 9 files, no declaration
changed — the registry layer is correct and stays exactly as it was.

Almost all of it is two landings that were never swept:

  * OPT1 + the C++ ext provider (logos-cpp-sdk#125). The ext table is
    described throughout as Rust-only, single-provider, with no
    differential, an Optional family "red on purpose", and two module
    sources that announce "DOES NOT COMPILE TODAY" — while the check
    measures 39 cases x 2 providers, 76 pass, differential-provider-pass
    37, and compiles both modules to get there.

  * the Qt consumer axis + Q1b. Two cases.json paragraphs say "this
    matrix has no driver for that surface" and point at known.json M3
    under `unmeasurable`; the driver runs three consumer points and M3
    moved to `xfail` long enough ago that `unmeasurable` now holds only
    the note explaining it moved.

The worst of it is the README, which is the front door: its hand-kept
known-broken table had drifted to eleven rows against a registry of four,
listing M1/M1b, M2, M4, M5/E1, E2, E3 and OPT1 as current defects after
each had moved to `fixed[]`, and omitting M4-residual, the live entry with
the most cells. That table is now the GENERATED one — run_matrix.py --md
already writes it and the flake check drops it at $out/known-broken.md —
pasted verbatim, with the regeneration command next to it. A table that
cannot be hand-edited cannot drift again.

Also corrected there: the documented command line omitted the two
--proxy-consumer flags that are the whole consumer axis, and the exit
status was listed as three statuses when the driver checks six. The three
missing ones — dead-skip, skip-passes, setup-failed — are each a way for a
run to look green while measuring less than it claims, which is the
opposite of a detail.

`expect_error` (cases.json schema comment) documented an expectation key
no driver ever implemented. A case written the way that paragraph
described takes have_want=False and is filed `skip`, which is not in the
failing-status set: present in the table, counted as coverage, asserting
nothing. The paragraph now documents `{"__error__": "<code>"}`, which is
what exists; logos-logoscore-py grows the guard that makes the mistake
impossible rather than merely undocumented.

Verified: both flake checks pass against this tree, and the full-contract
report diff against the pre-sweep run is ONLY the two known.json blocks
the report reproduces verbatim — every count, cell and coordinate
identical. The ext modules were recompiled, which is what proves the
comment edits did not touch code.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 18:48:11 -03:00
Dario LipicarandClaude Opus 5 a969c20e96 test(conformance): the LIDL optional contract, as cases, before the implementation (#37)
* test(conformance): the LIDL optional contract, as cases, before the implementation

`?T` is declared by every contract and read by no generator, in any language.
This writes down what it must mean, as 17 cells in the full_api_ext table plus
the contract delta on both ext providers, so the implementation has a target
instead of a behaviour to bless.

The contract these cells encode:

  * `?T` is TWO-state — a value of T, or empty — never three-state. Forced by
    "one LIDL type maps to one type per language": every target has exactly one
    empty inhabitant (None / nullopt / invalid QVariant / undefined).
  * Omitted and explicit-null are the SAME state on DECODE and DIFFERENT on
    ENCODE. Empty is spelled by OMITTING the key in a NAMED slot (a record
    field) and by null in a POSITIONAL one (arg, return) — a positional slot has
    no key to omit and the arity must never change. The round trip is therefore
    CANONICALISING, not identity, and several expectations here are deliberately
    not the argument echoed back.
  * A present-but-wrong-typed value in an optional slot is STILL AN ERROR.
    Optional widens the domain by exactly one inhabitant; it does not disable
    type checking. `hostile/Optional/scalar/wrong-type` is the cell that stops
    `?T` = `any` from passing, which is what all three backends do today.
  * The two spellings the spec binds to one meaning (`? name: T` and `name: ?T`)
    must emit identical code. Both are in the contract on purpose: today they do
    not even agree at runtime — the field flag is checked against the base type,
    the type kind falls to `Ty::Any` and is not checked at all.

Both ext providers are authored AT THE TARGET, which means neither compiles
until the generator work lands, and the ext check is red at build time until
then. That is recorded in known-ext.json (OPT0) rather than hidden: a fixture
written against the current behaviour cannot be the thing the fix is measured
against. Every cell that cannot pass is registered (OPT1: optionality is
unimplemented; OPT2: an empty optional in a positional slot is unspeakable on a
wire where null already means METHOD_FAILED, and the py driver cannot send a
top-level null at all), so the suite is honestly red now and reports xpass —
which fails the run — the day the generators are right.

The registry entries record what was MEASURED about the generators by executing
them: --header-to-lidl silently derives `any` from std::optional, the cdylib
gate rejects `?T` outright, and the emitted record codec ignores the field flag
in both directions.

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

* chore: re-pin logos-module-builder for the optional SDK stack

Carries logos-lidl#7, logos-protocol#37, logos-rust-sdk#31, logos-cpp-sdk#125
and the two module-builder re-pins (#177, #178) that make them reachable.

Both ext providers now BUILD, which is what OPT0 recorded as impossible:

  nix build .#test_fullapi_ext_cpp   -> exit 0  (was: undefined template
                                        Codec<std::optional<std::string>>)
  nix build .#test_fullapi_ext_rust  -> exit 0

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

* conformance: OPT1/OPT2 must declare which consumers they were measured on

run_matrix.py refuses to start otherwise:

  known.json xfail entry 'OPT1' has no `consumers`; an xfail must state which
  surfaces it was measured on

The entries were written while the ext providers could not build (OPT0), so
the driver had never reached them to reject them. Both are measured on the py
consumer, which is the only driver the ext table has.

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

* conformance: OPT1 is closed — measured, not assumed

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 09:23:59 -03:00
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