Commit Graph
14 Commits
Author SHA1 Message Date
Dario LipicarandClaude Opus 5 3d322bd315 fix(codegen): LIDL int/uint are 64-bit in the Qt spelling too (#113)
* fix(codegen): LIDL int/uint are 64-bit in the Qt spelling too

lidlTypeToQt mapped BOTH `int` and `uint` to plain `int`. Everywhere else in the
stack a LIDL int/uint is 64-bit — int64_t/uint64_t in C++ impls, i64/u64 in the
Rust SDK — so the Qt spelling broke the one-type-per-LIDL-type rule and lost
data: a Qt consumer reading a `uint` return got a SIGNED 32-bit value, so
anything above 2^31 came back wrong and anything above 2^63 was never
expressible.

int -> qlonglong, uint -> qulonglong, and returnConversion() gains the matching
accessors (toLongLong / toULongLong instead of toInt).

qlonglong/qulonglong rather than qint64/quint64 so the generated introspection
JSON uses the same names Qt's own metaobject normalisation produces — otherwise
a cdylib module's generated `signature` and a legacy module's
QMetaObject-derived one would disagree for the same LIDL type. Nothing looks
these strings up: the only QMetaType::fromName call in the stack is for
"LogosResult".

This changes two generated surfaces: the Qt consumer wrapper signatures and the
introspection JSON. Passing an int argument still converts implicitly, so
callers keep compiling; code that assigns a wrapper's return into an `int`
narrows and may warn, which is the bug being surfaced rather than a regression.

Tests: 168/168, with the type-mapping and client-emitter expectations updated to
the 64-bit spelling.

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

* feat(records): typed C++ structs for Qt consumers

Completes the Qt half of the type mapping this branch started. A `type Foo { … }`
in a contract now generates a real struct in the client header, so a consumer
writes `Status s = client.makeStatus();` instead of digging fields out of a
QVariantMap. One LIDL type, one type per language.

  lidlTypeToQt   - Named -> the record's struct (was QVariant)
                 - [Record] -> QList<Record>, {tstr: Record} -> QMap<QString,
                   Record>. QVariantList CANNOT hold a record without
                   Q_DECLARE_METATYPE, and a typed list is the point.
  client emitter - struct + inline ToVariant/FromVariant per record, emitted
                   before the class; conversions come after all structs so
                   records may reference each other. Recursive, so a field may
                   itself be [Status] or {tstr: bstr}.
                 - records pass by const&, decode on return, and convert at the
                   call site (sync and async)

bstr fields are QByteArray on purpose: logos-protocol's QVariant<->JSON
conversion already materialises the canonical {"_bytes": base64url} form as a
QByteArray and back, so the record conversions stay pure field mapping and binary
survives at any depth with no record-specific bytes handling.

Verified by COMPILING and RUNNING the generated code, not just asserting on text
— the string tests would not have caught either bug this found: [Record] first
mapped to QVariantList (appending a Status to it does not compile) and the decode
lambdas shadowed their accumulator. Extracted the emitted record block for a
contract with a nested record and a bytes field, compiled it against Qt6Core, and
round-tripped Batch -> QVariant -> Batch asserting items[0].port, the QByteArray
blob and the label all survive. Exit 0.

The LidlTypeToQt.NamedType expectation flips from "QVariant" to the struct name,
which is the behaviour change.

Tests: 169/169.

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

* fix(codegen): teach the lp/std consumer wrappers the 64-bit spellings

Caught while checking whether the SDKs are ready for the `any` migration, and it
is a regression THIS BRANCH would otherwise have shipped.

legacy/generator_lib.cpp generates the lp/std consumer wrappers a universal
module uses to call its dependencies. It matches type names against an
allow-list and falls back to QVariant for anything else:

    static const QSet<QString> known = {
        "void","bool","int","double","float","QString", … };
    if (known.contains(base)) return base;
    return QString("QVariant");

Once lidlTypeToQt reports `qlonglong`/`qulonglong`, every LIDL int/uint method
misses that list — so a typed `int` parameter would have silently become an
opaque QVariant in those wrappers. Worse than the truncation this branch set out
to fix, and invisible until someone read the generated header.

Adds the two spellings to both allow-lists, plus the conversions they imply:
QVariant->Qt (toLongLong / toULongLong), the std spellings (int64_t / uint64_t),
the QVariant->std return path, the Qt-style return, and the default-value case.
The existing `int` entries stay for legacy Qt plugins, whose QMetaObject still
reports `int` for a 32-bit parameter.

Tests: 171/171, with the allow-list pinned in both mapping test files —
including that an unknown spelling still falls back to QVariant, so the fallback
itself is not what regressed.

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

* feat(records): typed structs for C++ dependency wrappers

Closes the second record gap. The wrapper every C++ module actually gets for
its dependencies comes from the LEGACY generator (`--dep <name>=<lidl>`), not
the client-stub backend the previous commit taught about records — and there a
contract's `type Status { ... }` reached the consumer as an untyped bag:
QVariant on the Qt surface, LogosMap on the std/lp one. Worse than
inconvenient for a `bstr` field: the caller received the canonical
`{"_bytes": "..."}` envelope and had to know to unwrap it, while Rust and the
stub backend handed back real bytes.

Now, on all three api styles:

    struct Status { uint64_t port{}; std::vector<uint8_t> blob{}; };
    Status getStatus(logos::CallError* err = nullptr);
    std::string describeStatus(const Status& s, ...);
    std::vector<Status> listStatuses(...);

The struct is NESTED in the wrapper class (`InfoModule::Status`) because a
module consuming two deps that each declare a `Status` includes both wrappers
into one translation unit. Conversions are file-local statics in the generated
.cpp, so a Qt-free module's own TUs still never see QVariant or nlohmann.
Records reach parameters, returns, event callbacks, `[Record]` and
`{tstr: Record}` — at any depth, with bytes tagged throughout.

Same commit, the legacy path's half of the 64-bit fix: `lidlTypeExprToQtTypeName`
mapped BOTH int and uint to `int` ("wire-as-int for now"), so a `uint` method on
a dep reached a Qt consumer as a signed 32-bit value and a std/lp consumer as a
signed int64_t. Now qlonglong/qulonglong, matching the spelling the other half
of this PR gave the stub backend. `lpFromJsonExpr` grew the uint64_t branch it
needed — without it a mistyped payload THREW out of nlohmann's implicit
conversion instead of defaulting like every other scalar.

Verified by generating a contract with a record, a record-of-records, a `uint`
above 2^32 and a high-byte `bstr`, then COMPILING the output for qt/std/lp and
round-tripping the emitted conversions:
  - `{"_bytes":"gAH_"}` at every depth, decoding back to the same bytes
  - 4294967296 intact through both directions
  - garbage/missing fields default rather than throw
That compile is what caught the one real bug here: the container decode lambdas
declared `__m`/`__j`, shadowing the record decoder's own locals, so a
map-of-records field read from its own uninitialized local — it compiled with
nothing but a -Wuninitialized warning. Locals are `__acc`/`__src` now, pinned
by a test.

Also: 8 generator tests (one asserting an empty record set leaves every byte
of the output as it was), 179 total green; logos-test-modules builds and tests
green against this generator.

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

* fix(records): async record returns decoded a default-constructed struct

Two correctness holes in the record work, both silent, found while scoping the
cdylib provider side.

1. The async consumer overload emitted `qvariant_cast<Status>(v)` while the sync
   one emitted `StatusFromVariant(_result)`. The wire delivers a QVariantMap and
   no Q_DECLARE_METATYPE is emitted for the struct, so the cast does not fail —
   it returns a DEFAULT-CONSTRUCTED Status and the caller sees empty fields with
   no diagnostic. The sync path being correct is what makes it bad: the same
   call is right or wrong depending only on which overload the caller reached
   for. Async now decodes field by field through the same conversion.

   (The legacy dependency-wrapper generator already did this correctly — this
   was the experimental client-stub backend only.)

2. A record whose ONLY field is a tstr named `_bytes` is wire-identical to a
   canonical tagged byte string: `isTaggedBytes()` is checked before
   `is_object()` in both logos_codec.h and logos_json_convert.cpp, so such a
   record decodes as bytes and the struct silently disappears. The ambiguity is
   inherent to the tagged form — the codec's own comment says not to name a map
   key `_bytes` — but the generator can refuse to emit the one shape guaranteed
   to misdecode instead of leaving it to be found at runtime. Both front doors
   (the .lidl client-stub path and the --dep/--interface path) now reject it
   with a message naming the type and the fix.

   A second field disambiguates it (isTaggedBytes requires exactly one key), so
   that shape still generates — verified, not assumed.

181 tests, +2.

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

* feat(cdylib): records, [bstr], typed maps and nested composites on the C++ provider

The C++ cdylib backend could express scalars, `[scalar]`, `any` and an untyped
map. Everything else it rejected BY NAME — `[bstr]`, `[[int]]`, `{tstr: int}`,
and records, which the impl-header parser could not even declare because it
skipped `struct` outright. That is why the ext contract had to be Rust-only.

Four changes, in the order they matter:

  typeSupported()  recurses instead of whitelisting element names, which admits
                   [bstr], [[int]], [Record] and [{tstr: T}] in one rule; a
                   declared record is admitted; a map now REQUIRES a tstr key
                   (it used to `return true` for any map and then silently
                   flatten {int: tstr} to an untyped LogosMap, losing the key
                   type).
  lidlTypeToStdCdylib()  became total. Its `lidlTypeToStd` fallback answers
                   QVariantList / QVariantMap — Qt names in a Qt-FREE
                   translation unit — and only failed to appear because the
                   gate rejected everything that reached it. Widening the gate
                   made that fallback a live leak, so composites now recurse and
                   never reach it.
  <name>_types.h   new: the generated codec, recursive, with a FULL
                   specialization for std::vector<uint8_t> that wins over the
                   generic vector rule — which is what keeps a bstr tagged at
                   any depth instead of becoming a plain array of numbers. One
                   Codec specialization per declared record, field by field,
                   with the field path in the error.
  impl_header_parser  learned `struct` (two passes, because a record field may
                   name another record and the type mapper only answers Named()
                   for an already-registered name — one pass silently typed
                   `Blob inner;` as `any`), std::map<std::string, T>, and
                   recursion into vector elements so std::vector<Blob> is
                   [Blob] rather than falling through to `any`.

Records are only names the contract DECLARES: `void` is not a LIDL builtin, so
`-> void` arrives as Named("void"), and treating every Named as a record is the
exact trap that made the Rust generator emit `-> Void`.

Two things the interface JSON got wrong, both found by running it:

  - it spelled a record `Blob` and a `[Record]` `QList<Blob>`. Those are the
    CONSUMER's names, correct in a generated wrapper where the struct exists —
    but this JSON is the module's getMethods(), read by the host to marshal a
    QVariant, and there is no metatype called `Blob`. The host SIGSEGV'd on the
    first call to any record method. A record IS a variant map at that boundary;
    lidlTypeToQtWire() says so.
  - the types header emitted the structs. Header-first, the author owns them and
    the contract was derived from those very declarations, so it was a
    redefinition. It emits forward declarations and the codec.

Also: `jsonReturn` is set by the front end for any map return, which no longer
implies the C++ type IS nlohmann::json now that a typed map is
std::map<std::string, T> — checking the flag before the spelling emitted
`result.dump()` on a std::map. The spelling decides.

Scalars keep their nlohmann accessor verbatim rather than routing through the
codec: `.get<int64_t>()` TRUNCATES a float instead of throwing, and the
conformance matrix pins that leniency (hostile/int/fractional expects 3 from
3.7). Changing it would silently move behaviour something depends on.

The pinned-rejection test for [bstr] is INVERTED rather than deleted — the cell
it pinned still matters, only its answer changed — plus new tests for the
non-tstr map key rejection and for declared-vs-undeclared records. 183 tests.
Every existing module still builds; test_fullapi_cpp is unchanged.

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

* fix(records): only structs the API mentions become contract types

Teaching the impl-header parser to read `struct` (previous commit) published
EVERY struct in a header as a contract `type`. Two production modules already
carry private helpers:

  openmetrics-module      struct ModuleSource   (namespace scope, internal)
  logos-package-manager   struct PendingAction  (PRIVATE, inside the class)

Both were being published — a module's interface changing as a side effect of
an internal refactor, which is not something deriving a contract from a header
may do. PendingAction was published WRONG as well: its fields carry trailing
`// comments`, the field regex requires a line ending in ';', and the
unmatched fields were silently dropped. A record with a partial field list is
worse than no record, because it looks like a contract.

A struct now earns its place by appearing in a method or event signature —
transitively, since a published record's own fields may name others. Verified
on the real headers: package-manager and openmetrics publish zero types again,
while the ext provider keeps both Blob and Wrapper (Wrapper is reachable only
through Blob's use in a signature). Trailing comments are stripped before the
field match, so no field is dropped.

Two tests over a fixture carrying both an internal namespace-scope struct and a
private in-class one; 185 tests. test-modules and openmetrics both rebuild.

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

* docs(doctests): the generator round-trip now shows 64-bit ints and typed records

The two failing assertions in cpp-sdk-generator-roundtrip were documentation
asserting the OLD behaviour, and both changes are the point of this branch:

  int record(int id, …)          ->  qlonglong record(qulonglong id, …)
  QVariant translate(QVariant p) ->  Point translate(const Point& p, …)

`record`'s `id` is a `uint64_t` in the impl header, so the old signature was
handing a caller a SIGNED 32-bit value for a `uint` — the doc showed the bug.

The surrounding prose was wrong too, not just the expectations, so both blocks
are rewritten rather than patched:

  * Flow 3 now states the mapping as int->qlonglong / uint->qulonglong and says
    why (LIDL int/uint are int64_t/uint64_t in every other binding), pointing at
    `record` as the worked example.
  * The composite section claimed "records and optionals surface as QVariant".
    Records now generate a struct, `[Point]` a QList<Point> and `{tstr: Point}`
    a QMap<QString, Point>; maps of `any`, optionals and bare `any` still cross
    untyped and stay QVariant/QVariantMap — a record has a declared shape, those
    do not. The new text draws that line explicitly.

Expectations added for `struct Point` and `Point bounds(const QList<Point>&…)`
so the record path is pinned in the doc, not just described.

Verified the way CI runs it — `--release-for logos-cpp-sdk=feat/qt-64bit-numerics`,
which is what makes `{release}` resolve to this branch instead of master:
10 passed, 0 failed. (A plain local run builds master and is not
representative — that is why it still showed the old signatures.)

outputs/ regenerated; the diff also picks up unrelated pre-existing drift where
the committed Markdown had fallen behind the spec.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 17:24:49 -03:00
Dario LipicarandClaude Opus 5 8a40a98322 feat(cdylib): support [bstr] — arrays of byte strings (#111)
A universal module could take or return a single blob (`bstr`) but not a list
of them: `std::vector<std::vector<uint8_t>>` parsed to `[bstr]`, which the
cdylib gate rejected by name. Authors had to flatten to hex or base64 strings
by hand. logos-execution-zone-module hit this on
send_generic_private_transaction(..., program_dependencies), whose dependency
ELFs are exactly a list of blobs.

The gate excluded `[bstr]` because the array path decodes with a blanket
`expr.get<inner>()`, and `[bstr]` elements arrive as the canonical tagged
{"_bytes": base64url} OBJECT — nlohmann refuses that, and a number-array
element would silently bypass the base64 decode. So admitting the type needed
a per-element codec, not just a whitelist entry.

Adds one, on top of the scalar codecs already emitted:

  - lidlBytesListFromJson: element-wise lidlBytesFromJson, so each element may
    independently be tagged, a plain string or a number array; a non-array arg
    yields an empty list instead of throwing, matching the scalar decoder.
  - lidlBytesListToJson: element-wise lidlBytesToJson, so a returned or emitted
    list carries the tagged form per element instead of nested number arrays
    that no consumer decodes as bytes.

Wired into all three places the type can appear — method params, method
returns, event payloads — and both helpers are gated (usesBytesArray /
hasBytesArrayEventParam) so a module that never carries a byte-string array
gains no unused static function, matching how the scalar encoder is gated.

Nothing outside this generator needed changing: lidlTypeToStd already spelled
`[bstr]` as std::vector<std::vector<uint8_t>>, the wire form is protocol's
existing tagged-bytes encoding, and consumers see `[bstr]` as QVariantList
exactly like `[int]` — with nested QByteArray preserved through
qvariantToNlohmann since logos-protocol#23.

Tests: 171/171. New coverage for the param decode, the return encode, the event
encode, and the unused-helper gating; the test that enshrined the rejection is
now an eligibility + tagging assertion.

Verified end to end, not just as generated text — a module with `[bstr]` as
param, return and event payload, driven through logoscore over the real
transport:

  param  : json:[{"_bytes":"AH-A_w"},{"_bytes":""},{"_bytes":"3q2-7w"}]
           -> "3|4:0:255:510|0👎-1:0|4:222:239:824"  (byte-exact; the 0x80
              and 0xff bytes survive, and the empty element stays an element)
  return : [{"_bytes":"AH-A_w"},{"_bytes":""},{"_bytes":"3q2-7w"}]
  event  : {"arg0":[{"_bytes":"AH-A_w"},{"_bytes":""},{"_bytes":"3q2-7w"}]}

And lez_core's real header now generates, decoding program_elf with the scalar
codec and program_dependencies with the list one.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:58:14 -03:00
Dario LipicarandClaude Opus 4.8 c7444bc29a Follow-ups to #100: lp-consumer bstr decode, Qt-free cdylib event types, binary-event coverage (#102)
* cdylib events: Qt-free types, and drop the unused bytes encoder

Three follow-ups to the bstr event fix, all in the cdylib events sidecar --
a Qt-FREE translation unit:

- An `any`/map event parameter was emitted as a bare QVariant/QVariantMap,
  which does not compile there. Spell those as their nlohmann aliases
  (LogosMap / LogosList) and pull in <logos_json.h> when they appear.
- std::vector<std::vector<uint8_t>> fell through the impl-header parser's
  unknown-type fallback to `any`, so the cdylib gate admitted it and the
  generator then emitted QVariant. Parse it as `[bstr]` so the gate rejects it
  with a message naming the offending parameter.
- The bytes encoder was emitted into every module's sidecar, leaving an unused
  static function (-Wunused-function) wherever no event carries binary data.
  Emit it only when a bstr event parameter exists.

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

* lp consumer: decode bstr into std::vector<uint8_t>

The Qt-free (`lp`) consumer wrappers -- what every universal C++ module gets for
its dependencies -- had no QByteArray in their type tables, so a `bstr` event
parameter, method argument, or return degraded to QVariant and then to LogosMap.
A consumer subscribing to a binary event was handed the raw tagged JSON object
{"_bytes": "<base64url>"} instead of the bytes, with no generated decode.

Teach the tables about QByteArray (-> std::vector<uint8_t>) and marshal it
through the canonical tagged form in both directions: logos::bytesToJson on the
way out, logos::jsonToBytes on the way in. Those live in logos_json.h -- Qt-free
and protocol-free, so the generated wrappers and module code can share them.
The Qt apiStyle already did this via QByteArray::toBase64/fromBase64.

Without this, a subscriber written the obvious way --

    onBinaryReady([](const std::string&, const std::vector<uint8_t>& payload) {...})

-- compiles (nlohmann::json has an implicit conversion operator) and then throws
at runtime on every event, so the callback body silently never runs.

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

* tests: cover binary event payloads by value, not just by source text

The regression test for #99 asserts on generated source text, so it stays green
against an encoder that emits the wrong bytes. Add the value-level half:

- tests/sdk/test_logos_json_bytes.cpp exercises the canonical tagged-bytes codec
  against the RFC 4648 vectors, the URL-safe alphabet, every len%3 tail group,
  embedded NULs and high bytes, a 109,447-byte payload (the size from #99), and
  the lenient/padded decode paths.
- tests/experimental/test_lidl_gen_cdylib.cpp additionally pins the Qt-free
  spelling of JSON event payloads, the rejection of [bstr], and the omission of
  the bytes encoder from modules whose events carry no binary data.

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

* doctests: prove a binary event payload survives the round trip

Neither doc-test covered bytes-in-an-event -- the gap #99 fell through. The
generator round-trip carried `bstr` only as a method argument and return, and
the composition doc-test, which is the one that actually runs two modules under
logoscore and subscribes to an event, carried only a string. So a generator that
dropped every bstr event argument kept both of them green.

- cpp-sdk-module-composition: greeter_module gains a `blobReady(label, payload)`
  event and an `emitBlob(size)` method; orchestrator_module subscribes and
  reports the length AND a checksum of what it received. Length alone would not
  catch a corrupted payload -- a wrong alphabet round-trips to the same size.
- cpp-sdk-generator-roundtrip: sensor_module gains a `capture(id, frame: bstr)`
  event, and a new step shows the generated event body encoding it through
  lidlBytesToJson rather than pushing it raw.

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

* logos_json.h: include <cstddef> for size_t

The tagged-bytes codec uses size_t but relied on it arriving transitively
through the other includes. Include <cstddef> directly so the header is
self-contained. (Copilot review, PR #102.)

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:50:40 -03:00
haeliusandÁlex f032cf291e fix(header-parser): join multi-line method declarations before parsing (#91)
* fix(header-parser): join multi-line method declarations before parsing

* strip qualifiers/attributes before return-type matching (#92)

---------

Co-authored-by: Álex <alex93cabeza@gmail.com>
2026-06-18 08:30:32 -04:00
Dario LipicarandClaude Opus 4.8 1bc101df1f feat: cpp-generator consumes logos-lidl; delete embedded frontend (#89)
* feat: cpp-generator consumes logos-lidl; delete embedded frontend

The canonical LIDL frontend now lives in logos-lidl. cpp-generator links it
and keeps only the C++/Qt-specific parts (impl-header parsing, the gen_client/
gen_cdylib backends, the Qt type-name mapping).

- Delete the embedded lidl_lexer/parser/serializer/validator/ast.
- Add experimental/lidl_compat.h: brings logos-lidl's std AST into the global
  scope the backends use (via `using`), a qs() std::string→QString helper, a
  QTextStream<<std::string overload, and name-compatible shims (lidlParse/
  lidlSerialize/lidlValidate) so the emission code keeps compiling.
- Re-point impl_header_parser, lidl_gen_client (+ Doxygen /// docs on the
  generated client methods), lidl_gen_cdylib, lidl_emit_common, and legacy/
  main at lidl::ModuleDecl.
- CMake: C++17 + find_package(logos-lidl) + link logos-lidl::logos_lidl.
- bin.nix: distribute only the shared C++/Qt backend helpers (compat +
  impl_header_parser + emit_common) under share/lidl-frontend, not the frontend.
- tests: drop the 4 frontend test files (covered by logos-lidl now); the
  backend tests link logos-lidl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: pin logos-lidl to the C-ABI commit + lock it

The logos-lidl input was declared in flake.nix but missing from flake.lock,
so override chains that don't reach the nested input (the doctest harness
building a scaffolded module) couldn't resolve it. Pin the branch rev and
lock it so the component is self-contained. Re-point at master once logos-lidl
lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: re-point logos-lidl to merged master (#5)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:00:42 -03:00
Dario LipicarandClaude Opus 4.8 676154070c codegen: Qt-free outbound — ApiStyle::Lp typed wrappers over the lp_* C ABI (#88)
* ci: drop doctest chain pins — the qt-split chain is fully merged

logoscore-cli and module-builder masters now contain the chain; the
temporary --release-for pins (added so stacked-branch CI could resolve
compatible cross-repo revs) default back to latest releases.

* codegen: Qt-free outbound — ApiStyle::Lp typed wrappers over the lp_* C ABI

Adds a third generator flavor (ApiStyle::Lp, --api-style lp) whose typed
dependency wrappers + LogosModules umbrella call the logos-protocol C ABI
directly via a new header-only logos::LpClient, instead of LogosAPIClient.
This lets a module make outbound typed calls and event subscriptions with NO
Qt in its translation units — Qt stays confined to the QRO transport (inside
logos-protocol) and the generated plugin glue.

- cpp/logos_lp_client.h: header-only logos::LpClient (lazy lp_client_create
  on a baked origin; invoke / invokeAsync / subscribe; std<->nlohmann JSON;
  CallError out-param) + RAII logos::LpSubscription (unsubscribes on drop) +
  json<->std helpers. The C++ analog of rust-sdk PluginProxy.
- generator: makeHeaderLp/makeSourceLp emit the Lp wrappers; the Lp umbrella
  drops the LogosAPI ctor and bakes this module name as the lp_client origin
  (LogosModules() default-constructible). Qt/Std emission is byte-unchanged
  (dispatch added at the top of makeHeader/makeSource).

Verified: generator builds; generated wrappers + umbrella compile to .o with
ONLY cpp-sdk + logos-protocol headers + nlohmann (no Qt); cpp-sdk tests pass.

* cdylib: wire the Qt-free typed dependency surface (modules()) into the impl

When a cdylib module declares dependencies, the generated exports now include
the Lp umbrella (logos_sdk.h) and construct LogosModules() + maybeSetLogosModules
on the impl just before onContextReady — so the author can call
modules().<dep>... and subscribe to dep events from a Qt-free cdylib. Guarded
on module.depends so dependency-less cdylib modules are byte-unchanged.

The umbrella + dep wrappers themselves are produced by the --general-only
--api-style lp generation; feeding the dep .lidl files into that during the
module build is the remaining build-system wiring (module-builder + plugin-qt
dep resolution).

* cdylib: wire modules() unconditionally (deps come from metadata, not the .lidl)

The umbrella wiring was guarded on the .lidl module.depends, but a cdylib
module declares its dependencies in metadata.json#dependencies — the .lidl
contract.depends is typically empty — so modules() was left null and a typed
outbound call segfaulted. Always include the generated logos_sdk.h umbrella
and maybeSetLogosModules(impl, new LogosModules()) before onContextReady; the
overload is a no-op for context-less impls and the umbrella codegen emits an
(empty) logos_sdk.h for every cdylib, so this is safe in all cases.

* fix(headers): ship logos_lp_client.h in the include/cpp source-export root

A cdylib module's generated dep wrapper includes "logos_lp_client.h" and,
transitively, "logos_result.h". The wrapper is compiled with the cpp-sdk
source-export include root (include/cpp), so logos_lp_client.h must sit
beside logos_result.h there — a quoted include resolves siblings relative
to the including file's directory. Previously logos_lp_client.h shipped
only at the top-level include/ (the CMake-export layout via cpp/
CMakeLists.txt), so it pulled in include/logos_result.h while the impl's
logos_module_context.h pulled include/cpp/logos_result.h. Those are two
distinct realpaths under the symlinkJoin, so #pragma once could not dedup
them and StdLogosResult was redefined. Install every std header into both
roots so a single TU only ever sees one logos_result.h.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cdylib): route logos_module_accept_token into the protocol TokenManager

The generated module-impl export stored accepted tokens in a process-local
std::map (g_tokens) that nothing ever read, so a cdylib module's OUTBOUND
lp_client (modules().<dep>...) never saw the capability_module bootstrap
token the host delivers at load. The automatic requestModule flow then ran
unauthenticated: capability_module rejected requestModule, no per-target
token was issued, and the cross-module call was rejected (returning a
default-constructed result, e.g. 0).

Forward the token into lp_token_save, which writes the same
TokenManager::instance() singleton the cdylib's lp_client reads. The
capability/token handshake now completes and typed Qt-free outbound calls
return real results. Drop the dead g_tokens map + mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lidl): exclude LogosModuleContext hooks from header-derived contracts

--header-to-lidl parses an impl class's public methods. An impl commonly
overrides onContextReady() (and could redeclare a context accessor) in its
own public section, so the derived LIDL would include onContextReady /
modules / modulePath / instanceId / instancePersistencePath. Those are
framework plumbing, not API methods — and feeding them to the cdylib
backend breaks cdylib-eligibility (e.g. the inherited accessors' Qt-free
return-type check), which is exactly what header-first universal modules
now hit. Skip the reserved LogosModuleContext names in the parser so both
the Qt --from-header path and the cdylib --header-to-lidl path emit clean,
API-only contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cdylib): support the full std type set in header-derived contracts

Routing core universal modules through the cdylib backend surfaced gaps
between the cdylib subset and what the std apiStyle handled — a universal
module that built under std must also build as a header-first cdylib.

- lidl parser: restore the return-shape flags (resultReturn / jsonReturn)
  from the parsed return TypeExpr, so a header -> .lidl -> cdylib round-trip
  (the universal path, needed to feed the Qt glue) preserves the semantics
  the impl-header parser sets from C++ types (StdLogosResult -> result;
  LogosMap/LogosList -> json). Without this the cdylib codegen/eligibility
  mis-handled result / map / list returns.
- cdylib eligibility + dispatch: `void` is not a lidlBuiltinType, so the
  parser yields it as a Named "void" (header path uses empty name) — treat
  both as void in the eligibility check and the dispatch (was relying on
  lidlTypeToQt=="void", which didn't match Named "void" -> generated an
  `auto result = <void call>`).
- typeSupported: accept `any` (both directions), `void`/`result` (returns),
  arrays-of-any, and Map ({k:v}/LogosMap) — the Qt-free-via-nlohmann set.

Verified: a probe with void / LogosMap / LogosList / StdLogosResult /
const returns is cdylib-eligible and dispatches correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lidl): carry method/event descriptions across the .lidl round-trip

Header-first universal modules go header -> .lidl -> cdylib backend. The
impl-header parser captures /// and /** */ doc comments into method/event
descriptions, but the .lidl serializer emitted only the signature, so the
descriptions were dropped — introspection (lm methods / --json, getMethods)
then showed no docs (regressing the wrap-external-lib + tutorial doctests).

Serialize each method/event's description as a trailing `description "..."`
clause (escaped for the string literal; the lexer already decodes \\ \" \n
\t) and parse it back in parseMethodDef/parseEventDef. Module description
now escaped too. Verified: /// docs survive header -> .lidl -> getMethods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lp): bound-interface wrappers are handles over umbrella-owned state

The Lp interface (bind_<iface>) wrapper owned its LpClient + RAII
subscriptions BY VALUE, so the idiomatic transient handle —
  modules().bind_calculator(p).fibonacciAsync(...)
  modules().bind_calculator(p).onVersionReady(...)
— tore the client/subscription down when the temporary died, cancelling
the async callback and the event subscription. (Sync calls completed before
the temporary's destruction, so they worked; the Qt/std flavor works because
its handle is thin over a LogosAPI-owned persistent client.)

Make the Lp Bound wrapper a THIN, copyable handle over `State { LpClient
client; vector<LpSubscription> subs; }` that the LogosModules umbrella OWNS
per provider (std::map<provider, unique_ptr<State>>) for the module's
lifetime. bind_<iface>(p) creates/looks up the State and returns a handle to
it, so a transient handle's async/event registrations outlive it. Concrete
(Static) dep wrappers are unchanged — they're already persistent umbrella
members, so by-value ownership is fine there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cdylib): lenient bytes-param decode (string / array / tagged)

A universal module's bstr (std::vector<uint8_t>) PARAM arrived empty when
the caller sent a plain string rather than the tagged {"_bytes": base64url}
form — lidlBytesFromJson only accepted the tagged object, so
byteArraySize("12345") and byteArraySize(b"\x01..") both saw 0 bytes (the
return direction already worked). The std path was lenient (a QString or
QByteArray arg both became bytes). Accept all three forms: a plain JSON
string (raw UTF-8 bytes), an array of byte values, and the tagged
{"_bytes"} form (base64url). Return direction unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cdylib): a number arg to a bytes param decodes as its decimal text

byteArraySize("12345") arrives as a JSON number (the logoscore CLI's type
auto-detection turns the string "12345" into int 12345), and the Qt path
gives QVariant(int)->QByteArray "12345" (5 bytes). The cdylib bstr decode
returned 0 for a number. Treat a JSON number as its decimal text bytes
(j.dump()), matching the Qt behaviour, so a bare-number arg to a bytes
param round-trips identically. Verified: byteArraySize 12345 -> 5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:47:09 -03:00
Dario LipicarandClaude Opus 4.8 bb6d87b6ec fix: parse declarations on same line as logos_events: / access specifier (#76) (#81)
* fix: parse declarations on same line as logos_events/access specifier (#76)

The impl header parser updated its section state and immediately broke out
of line processing when it matched `logos_events:` (or `public:`/`private:`),
discarding any declaration on the same physical line. This meant clang-format
/ prettier output like

    logos_events : void versionReady(const std::string &version);

silently dropped the event, while the newline-separated form parsed fine —
the same valid C++ was handled differently based on formatting.

Strip any leading section specifiers in a loop, updating the section state,
then let the remainder of the line fall through to the declaration parser.
Brace counting still happens once per physical line and blank-line doc-comment
reset is preserved.

Adds a regression test (SameLineSectionSpecifiers) with a fixture covering the
exact prettier form from the issue, a follow-on same-line event, the newline
form alongside it, and the symmetric inline `public:` method case.

Fixes #76

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: attach doc comments to same-line logos_events/access-specifier decls

Address review feedback: the first pass cleared pendingDoc on every
specifier match, so a `///` comment above a collapsed
`logos_events : void foo();` did not attach to the event. In the collapsed
form there is nowhere else to put the doc comment, so this left
documentation formatting-dependent — the same bug class as #76, one level up.

Only clear pendingDoc for a *bare* specifier (a section boundary, matching
Qt `signals:` semantics); when a declaration shares the line, keep the
pending doc so the declaration parser attaches it.

Extend the fixture with a `///`-documented same-line event and assert the
description is captured.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:46:15 -03:00
Dario LipicarandClaude Opus 4.8 7b62ac2017 Per-event documentation + getPluginEvents introspection (#71)
* Add per-event documentation + getPluginEvents introspection

Mirror the per-method documentation pipeline for events. Events
(declared in a universal module's logos_events: section) now carry a
description parsed from their /// doc comments, and are introspectable
at runtime via a new getPluginEvents framework call.

- lidl_ast: EventDecl gains a description field.
- impl_header_parser: capture the event's doc comment (previously
  discarded) and an optional metadata.json events[].description.
- lidl_gen_provider: generated universal provider emits
  getEvents() override, mirroring getMethods() (name/signature/
  parameters/description; no returnType/isInvokable — events are void).
- logos_provider_object: default-empty virtual getEvents() so the
  legacy provider path and QtProviderObject inherit empty.
- module_proxy / qt_provider_object: intercept getPluginEvents next to
  the getPluginMethods special-case.
- docs: spec + README event-documentation notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add unit tests for event documentation + getEvents generation

Address review feedback (#71): cover the event-introspection paths that
previously only had method-side tests.

- impl_header_parser test: assert metadata.json events[].description is
  parsed; new documented_events fixture asserts `///` doc-comment
  capture on a logos_events: block (multi-line joined with \n,
  adjacent-only, plain // ignored).
- lidl_gen_provider test: assert the generated dispatch contains
  getEvents() emitting each event's name/signature/parameters and an
  escaped description, and that events carry no returnType/isInvokable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fold event introspection into getMethods() to keep the provider ABI stable

The previous approach added a getEvents() virtual to LogosProviderObject,
which inserted a new vtable slot and shifted every later slot — an ABI
break that would misdispatch virtual calls whenever an old and new
host/module were mixed across the in-process plugin boundary.

Instead, report events INSIDE the existing getMethods() call: it now
returns the module's whole interface, with each entry tagged
type "method" or "event" (events omit returnType/isInvokable). The
provider vtable is therefore byte-for-byte unchanged, so old/new hosts
and modules stay binary-compatible — a new host reading an old module
sees no event entries (zero events), and an old host reading a new
module just ignores the "type" field (cosmetic). An entry with no
"type" is treated as a method.

- logos_provider_object.h: remove the getEvents() virtual; document that
  getMethods() carries both, and why.
- generator (lidl_gen_provider): emit events as type "event" entries
  inside getMethods(); tag methods type "method"; no getEvents() output.
- module_proxy / qt_provider_object: getPluginMethods()/getPluginEvents()
  are now type-filtered views of getMethods(), plus a new
  getPluginInterface() returning the whole list. (These are name-
  dispatched Q_INVOKABLEs, not vtable surface — adding them is safe.)
- tests: generator asserts events fold into getMethods() tagged "event";
  ModuleProxy asserts the three filtered views; parser tests unchanged.
- docs: spec/project/docs/README updated, incl. an ABI rationale note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:06:12 -03:00
Dario LipicarandClaude Opus 4.8 760916e97b Parse method doc comments into per-method description (#70)
* parse adjacent method comments to populate description

* preserve line breaks in method descriptions

Join doc-comment lines with newlines instead of spaces (markers stripped,
leading/trailing blank lines dropped, interior blanks kept), and escape \n
when emitting the description into the generated getMethods(). Both codegen
paths updated; docs corrected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* don't count braces inside comment lines (impl-header parser)

A brace in a doc/line comment (e.g. `/// returns { ... }`) no longer affects
class-scope tracking, which previously could make the parser think the class
ended early and drop later declarations. Addresses review feedback on #70.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:38:27 -03:00
Dario Lipicar c71089abe9 remove legacy emitEvent (#69) 2026-06-02 15:54:54 -03:00
Dario Lipicar 8bdbd13848 Extend universal modules with module context (#61)
* extend universal modules with module context

* implement module calls and events for universal modules

* pr comments
2026-05-19 12:49:15 -03:00
Iuri Matias f7c855b110 add logos result type (#55) 2026-04-21 09:38:37 -04:00
Iuri Matias 1468180b25 add support for new types (#50) 2026-04-13 13:29:26 -04:00
Iuri Matias d633575677 add IDL parser & generator (wip) (#33)
* add IDL parser & generator (wip)

* fix fixtures issues affecting tests

* fix fixtures issues affecting tests
2026-03-31 16:29:35 -04:00