Remove the value blob; fixed-size arrays cross as Kotlin primitive arrays - #209
Conversation
A generated class mirrors a Rust type that derives `PartialEq`/`Eq`, so two
values with equal contents must compare equal. Kotlin arrays compare by
IDENTITY, so every `ByteArray`-backed property broke that silently:
Timestamp(1uL, byteArrayOf(1,2,3)) == Timestamp(1uL, byteArrayOf(1,2,3))
// false
Kotlin is inconsistent here rather than uniformly identity-based: a data
class's generated `hashCode`/`toString` DO special-case arrays
(`contentHashCode`/`contentToString`), its `equals` does not. So a broken
value hashes equal and then fails `equals` — it finds the right HashMap
bucket and is rejected there. `==` is the observable defect; a hashCode
check alone would not have found it.
`equality::content_equality_members` emits `equals`/`hashCode`/`toString`
for any class with an array-backed constructor property, and nothing for
the rest, so existing classes keep the compiler's own generation. Three
emitters feed it: `data_class!` (render.rs), value blobs and
`sealed_class!` variant payloads (kotlin_emit.rs).
Value blobs stop being `@JvmInline value class`. Kotlin 1.9 — the version
this generator targets downstream — rejects `equals`/`hashCode` members on
a value class outright ("Member with the name 'equals' is reserved for
future releases"), and the typed-equals replacement is experimental behind
an opt-in every consumer would have to set. An inline value class simply
cannot carry value equality at that language level, so a value blob is now
a plain `data class`. That costs one small allocation per crossing at the
wrapper tier; the JNI ABI is untouched, because externs already declare
`ByteArray` and the wrapper passes `.bytes` explicitly.
Coverage: nothing exercised the broken shapes — covertest had the `Stamp`
value blob but no data class with a `ByteArray` field, which is exactly why
this reached a consumer. `BlobValue` adds both (a `Vec<u8>` field beside a
scalar, plus a nested value blob) and the new section asserts content
equality, hash equality, HashSet de-duplication, `toString`, and that each
component participates. Verified the section fails without this fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`BlobValue` carried an `n: i64` that earned nothing. The generator has two per-property branches — array (`contentEquals`) and non-array (`==`) — and `n` took the identical path as the nested `stamp`, emitting identical code. Two fields already exercise the multi-property `hashCode` fold, so it was padding. Removing it exposed a gap it had been hiding: the array sat FIRST, so the fixture only produced `var result = id.contentHashCode()`. A real value has the bytes last (`Timestamp(ntp64, id)`), which emits the other form, `result = 31 * result + id.contentHashCode()`. The fields are now ordered `(stamp, id)` so the covered shape is the one downstream actually gets. Verified the remaining two fields still catch the defect on their own, by suppressing the members for `BlobValue` alone: the data-class assertion fails without them, independently of the value-blob assertion above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both reported on the PR, both real, both mine. **Whole-object input read the wrong descriptor.** A value-blob field's JVM slot was `[B` only while the wrapper was `@JvmInline`-erased. It is a real class now — it has to be, to carry value equality — so `struct_input_body`'s `ProjectionKind::ValueBlob` branch has to read the wrapper object and then its `bytes`. The old lookup raised `NoSuchFieldError: BlobValue.stamp [B` on the first decode. A null wrapper still yields a null `[B`, so the field's own converter carves `None` exactly as before. `read_kotlin_property` (sealed-class payloads) made the same assumption by falling through to `jni_field_access`, which maps a `JByteArray` wire to `[B`. Fixed there too rather than waiting for it to be reported: the two paths differ only in which JVM object they read the field off. **Content operators stopped at the property.** `Vec<Vec<u8>>` resolves and compiles, and its `List<ByteArray>` inherits `ByteArray`'s identity equality, so equal chunks compared unequal and `toString` printed `[[B@3830f1c0]`. `array_bearing` / `eq_expr` / `hash_expr` / `str_expr` now recurse through containers to the arrays underneath. A container of *classes* is untouched — those already compare by value, so only an array at the bottom makes a property array-bearing. The container hash folds with the same 31 multiplier `Arrays.hashCode` uses, so a `List` and the array it came from agree. Coverage for both: `BlobValue` gains a `chunks: Vec<Vec<u8>>` field and is declared `.jobject_input()` with a `blob_value_echo` round trip. Verified each catches its own defect — reverting the recursion reproduces the identity-compared chunks, reverting the descriptor reproduces `NoSuchFieldError` — rather than assuming the assertions bite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The enabling half of removing the value blob. `[T; N]` had no converter in
either direction — `Unresolved { key: TypeKey("[u8 ; 16]") }` — which is the
only reason a `Copy` struct with an array field had to cross as its raw
memory image.
`prim_array` maps each of the eight `JniPrim` scalars to its
`JPrimitiveArray` peer, bulk-copied through `set_*_array_region` /
`get_*_array_region`, so a fixed-size array boxes nothing and does NOT take
the `Vec<T>` -> `List<T>` path. Wider unsigned elements carry the raw bit
pattern in the signed array, matching the existing scalar rule (`u64`
already crosses as a raw `jlong`); Kotlin's own `ULongArray` is
`@ExperimentalUnsignedTypes` and would push an opt-in onto every consumer.
`N` is never needed at generation time — it is often a const path rather
than a literal — so the decode leans on `TryFrom<&[T]> for [T; N]` and lets
rustc infer it. That `try_into` IS the length check. Element conversion is
by cast, never a transmute: a `jboolean` is a `u8`, and reinterpreting a
byte of `2` as a Rust `bool` would be UB, which is the hazard that retires
the blob in the first place.
Four separate tables encoded "which wires are object-shaped" and "which
Kotlin types are arrays". Adding seven wires needed all four updated and I
only found that by breaking three of them in turn, so they are now one
table each in `wire_access` (`is_jni_reference_wire`, `KOTLIN_PRIM_ARRAYS`)
with the other sites delegating.
`Stamp` moves to `data_class!`: two scalars, so it crosses as its fields
with no array at all. Its covertest section becomes the NEGATIVE equality
case — a class the content operators must leave alone. The two binding-error
sections that sourced their failure from the blob's byte-length guard are
re-pointed at the fixed-size-array length guard, its direct successor.
Coverage: `Arrays` carries one field per primitive family member plus a
`[u64; 2]` pinning the raw-bits rule, round-tripped both directions with
per-element value checks (an equality-only check between two echoes would
survive a cast bug).
Value-blob removal is INCOMPLETE here — the converters are gone but the
declarator and its projection kind remain; the next commit finishes it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A `value_class!` type crossed as the **raw memory image** of the Rust
struct. Fixed-size array support (previous commit) removed the only reason
that existed, so the whole mechanism goes: the declarator, `ValueClassDecl`,
`DeclaredKind::Value`, `ProjectionKind::ValueBlob`, `WrapKind::Blob`,
`write_value_blobs`, the `__assert_copy` guard, and ~50 match arms across
17 files — nearly all of them arms that simply disappear.
What the representation cost, all of it now gone:
* **Unsound.** Input `read_unaligned::<T>()`-ed caller-supplied bytes after
a LENGTH check only. `Copy` never implied every bit pattern was a valid
`T`, and there is no trait that does — a `struct S { flag: bool }` and a
byte of `2` was UB. Array decode casts per element instead (`*x != 0` for
`bool`), so no invalid value is constructible.
* **Layout-dependent.** The JVM held a `repr(Rust)` struct's memory image,
padding included, for a layout carrying no stability guarantee.
* **Unequal.** It surfaced as a `ByteArray`, which compares by identity —
the defect this branch started from.
`ValueClassDecl` was a strict subset of `DataClassDecl`, so both call sites
migrated by changing the declarator alone. `Stamp` is two scalars, so it
crosses as its fields with no array at all; `ZenohId` keeps a `ByteArray`
property via its `[u8; N]` field, and its generated Kotlin is byte-identical
apart from the now-false blob KDoc.
Also fixed, both found by the removal rather than assumed:
* **Consts in type position were never qualified.** `QualifyEmittedTypes`
implemented only `visit_type_path_mut`, but an array length is an
EXPRESSION path, so `[u8; ZENOH_ID_MAX_SIZE]` emitted a const that was
not in scope. Added `visit_expr_path_mut`, restricted to registry-indexed
const idents — expression paths are also every local in a converter body,
and qualifying those indiscriminately would rewrite `v` and `env`.
* **`is_leaf_vec_element`'s doc contradicted its body**, claiming opaque
handles were excluded where `cfg.is_opaque()` includes them (its sibling
said so correctly). Pre-existing; corrected while rewriting that sentence.
Docs swept across both the adapter and `api/core` — including the public
`Prebindgen::leaf_vec_fold_elements` trait docs, which listed a leaf kind
that no longer exists. The two surviving "raw-memory value blob" mentions
are deliberate provenance: they explain why `prim_array` casts instead of
transmuting, which is the rationale that outlives the removed feature.
334 lib tests, PASS - 46 sections, fmt + clippy clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| /// idents the registry indexes as consts are rewritten: expression paths | ||
| /// are also every local variable and function name in a converter body, and | ||
| /// qualifying those indiscriminately would rewrite `v` or `env`. | ||
| fn visit_expr_path_mut(&mut self, ep: &mut syn::ExprPath) { |
There was a problem hiding this comment.
This visitor rewrites expression paths in the entire generated item, not only const expressions in type positions, so a captured const can collide with generator-owned locals. I reproduced it with the legal source:
#[allow(non_upper_case_globals)]
#[prebindgen]
pub const env: usize = 4;
#[prebindgen]
pub struct Arrays {
pub bytes: [u8; env],
}The const need not even be declared to JniGen (prebindgen reports it as skipped). Because registry.consts still places env in const_names, generation rewrites every JNI local use:
perftest_flat::env.get_java_vm()
converter(&mut perftest_flat::env, value)The isolated covertest build then fails with roughly 1,750 errors such as no method named get_java_vm found for type usize.
Please scope this qualification to syn::TypeArray::len rather than installing a global visit_expr_path_mut pass. A visit_type_array_mut implementation can visit the element normally and run a small const-path visitor only over arr.len; local variables cannot occur in that type-level expression. A regression using a lowercase const named env would pin the distinction.
The const pass was installed as a whole-item `visit_expr_path_mut`, so it
reached every expression path in the generated file — including the locals
a converter body threads (`v`, `env`). Restricting it to registry-indexed
const idents was not enough, because a source crate may legally declare one
that collides:
#[allow(non_upper_case_globals)]
#[prebindgen] pub const env: usize = 4;
Every `env.get_java_vm()` then became `myflat::env.get_java_vm()` — ~1750
errors of the form `no method named get_java_vm found for type usize`. The
const need not even be declared to JniGen; being in `registry.consts` was
enough.
An array length is the only place a const legitimately appears inside a
type, and a local can never appear there, so the pass now runs via
`visit_type_array_mut` over `arr.len` alone (`QualifyConstPaths`), with the
element visited normally.
The regression names its const `env` on purpose — the same name as the
`&mut JNIEnv` local — and asserts in both directions: the length IS
qualified, and the identically-named local is NOT. Neither assertion alone
pins the behavior, and the pair fails under the old pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Confirmed and fixed in What I got wrongI did consider this hazard — the commit message says "expression paths are also every local in a converter body, and qualifying those indiscriminately would rewrite FixExactly as you proposed — Why no test caught itBecause the corpus contained only consts that follow Rust naming convention:
So the real downstream case exercised the qualification path and passed, and nothing exercised the collision. Triggering it needs a lowercase const, which requires Regression
assert!(rc.contains("[u8;myflat::env]")); // length IS qualified
assert!(rc.contains("env.byte_array_from_slice")); // local is NOT
assert!(!rc.contains("myflat::env.byte_array_from_slice"));
assert!(!rc.contains("myflat::env,"));Neither direction alone pins the behavior — the first passes under a no-op pass, the rest pass if qualification is simply deleted. I verified the pair fails under the old whole-item pass before keeping it, and it reproduces your symptom precisely ( 335 lib tests, |
|
|
||
| impl syn::visit_mut::VisitMut for QualifyConstPaths<'_> { | ||
| fn visit_expr_path_mut(&mut self, ep: &mut syn::ExprPath) { | ||
| if ep.qself.is_none() && ep.path.leading_colon.is_none() && ep.path.segments.len() == 1 { |
There was a problem hiding this comment.
This only qualifies bare, single-segment constants, so an associated const used as the length remains in the generated crate scope. I reproduced it with both types declared to JniGen:
#[prebindgen]
pub struct ArrayLen { pub marker: u8 }
impl ArrayLen { pub const N: usize = 4; }
#[prebindgen]
pub struct Arrays { pub bytes: [u8; ArrayLen::N] }cargo build -p covertest-kotlin --offline then emits [u8; ArrayLen::N] in three converter signatures/ascriptions and fails each with E0433: use of undeclared type ArrayLen; the expressions need to be perftest_flat::ArrayLen::N. Since the fixed-array feature accepts ordinary Rust length expressions, please also qualify the leading source-type segment of multi-segment paths inside arr.len (the existing source_names map has the origin when that type is declared), and add an associated-const regression.
An array length is an ordinary Rust const expression, so it reaches the source crate two ways, and the pass handled only one. `[u8; MAX]` (one segment) was qualified; `[u8; Holder::N]` (an associated const) was left bare and the generated file failed with `E0433: use of undeclared type Holder`. The shape decides which map applies: one segment names a const directly and is looked up in `const_names`; more than one means the LEADING segment is the type that owns the const, looked up in `source_names`. Only that leading segment is rewritten — `::N` stays relative to the type it belongs to, so the result is `myflat::Holder::N` and never `myflat::Holder::myflat::N`. The regression gains the associated-const case beside the existing free const. Its assertions name the two CODE positions (return type, param type) rather than matching the type's spelling anywhere: the bare form legitimately survives inside the decode's diagnostic string, which names the type as the source wrote it, and a blanket negative match on the whole file would fail on that message. Verified the pair fails with the leading-segment lookup removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Confirmed and fixed in The gapYou're right that an array length is an ordinary Rust const expression, so it reaches the source crate two ways and I handled only one. The shape now decides which map applies:
Only the leading segment is rewritten; One thing worth recording, because my first assertion was wrongI initially asserted the bare spelling appears nowhere in the file. That failed even with the fix applied, and the reason is legitimate: the decode's diagnostic embeds the type as the source wrote it — That message should name the type as declared, not the generated-crate path. So the assertions now name the two code positions (return type, param type) instead of pattern-matching the whole file. Worth flagging because a naive "bare form must not appear" check would either fail spuriously or push someone into qualifying a user-facing message. RegressionExtended the existing test rather than adding a second one, so the free-const and associated-const cases are pinned side by side against the same fixture — including the 335 lib tests, Boundary I did not crossQualification uses |
Stage T's spec (#190) enumerated `SequenceKind` as `Vec | Slice | CowSlice` — the three *unbounded* spellings — so `[T; N]` fell through to a `Leaf`, verified: `[u8; 16]` interned as `Leaf(TypeKey("[u8 ; 16]"))`, standalone and as a struct field. That hid the element type from the tier whose job is structure, which is the same objection that put `Result` in as a `Choice` rather than an opaque leaf. It was invisible while nothing crossed the boundary as an array; #209 makes `[T; N]` a first-class boundary shape (a Kotlin primitive array), so the gap became load-bearing. The length is **carried**, not left for a consumer to re-read off the type: #208 (packing a small fixed array into scalar slots) is a Tier 1 decision that cannot be taken without it. `ArrayLen` splits by what a consumer can actually do — `Literal` is actionable immediately, `Named` must be resolved against the registry first, `Other` is recorded verbatim so nothing is silently dropped. `[u8; ZENOH_ID_MAX_SIZE]` is the real spelling in zenoh-flat, so "the length is a literal" is not an assumption this tier may make. A *qualified* length path is `Other`, matching the rule `source_item_ident` already applies to types. `SequenceKind` loses `Copy`, since `ArrayLen` owns an ident or a string. Three tests: arrays are sequences whose element keeps its structure and its use qualifier; the length records what the source spells, across all four forms; and two arrays of one element type but different lengths are distinct nodes while a repeated one still interns once. Corrects #190. Part of #187. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| let module = if ep.path.segments.len() == 1 { | ||
| self.const_names.get(&ident) | ||
| } else { | ||
| self.source_names.get(&ident) |
There was a problem hiding this comment.
This lookup still makes qualification depend on the associated-const owner being declared as part of the JniGen surface. I reproduced the remaining case by removing only .class(data_class!(Holder)) from the new regression while keeping the captured Holder item and declared Blob: the targeted test then generates bare [u8; Holder::N] in the converter return/parameter types and fails (a compiled consumer hits the same E0433 as before).
Holder is not a boundary field type here; it is only the Rust namespace for a compile-time length. Requiring its declaration emits an otherwise-unused Kotlin class merely to make generated Rust compile, and is asymmetric with the free-const path above, which qualifies indexed consts even when they are skipped by JniGen. Please derive the associated-owner lookup from all indexed source structs/enums plus origin_module (or an equivalent registry-wide map), and leave Holder undeclared in the regression so this opt-in boundary is pinned.
The associated-const lookup read `source_names`, so `[u8; Holder::N]` only qualified when `Holder` was declared to JniGen. That is the wrong condition: `Holder` is not a boundary type here, it is the Rust namespace that owns a compile-time length. Requiring its declaration would emit an otherwise-unused Kotlin class purely to make the generated Rust compile — and it contradicted the free-const path one line above, which qualifies any indexed const whether or not JniGen declared it. Both now read one registry-wide map (`length_names`: indexed consts, structs and enums → `origin_module`), so the two halves of a length expression are symmetric. `QualifyConstPaths` becomes `QualifyLengthPaths`, and the leading-segment rule is unchanged: whichever shape the path has, it is the FIRST segment that carries the origin module, and everything after it stays relative. The regression leaves `Holder` undeclared, so the opt-in boundary is pinned rather than merely satisfied. Verified it fails with the registry-wide lookup removed, reproducing the reported bare `Result<[u8; Holder::N]`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Fixed in I defended the old behavior as "consistent — same condition under which a bare ChangeBoth halves of a length expression now read one registry-wide map (
Regression
That is the third distinct defect this one visitor has had (whole-item scope → single-segment-only → declared-surface-only). Each was found by you rather than by the tests, and each time I shipped on reasoning where a test would have settled it; the regression now covers all three shapes against one fixture, each verified red before green. 335 lib tests, |
| for ident in registry.structs.keys() { | ||
| add_length_name(ident); | ||
| } | ||
| for ident in registry.enums.keys() { |
There was a problem hiding this comment.
The registry-wide map still omits functions, but an array length can call a source-defined const fn. I reproduced this on c103958 by adding an indexed-but-undeclared item and one field to the new regression:
pub const fn array_len() -> usize { 4 }
// in Blob
pub called: [u8; array_len()],The focused test reports skipping undeclared #[prebindgen] fn array_len and generates bare [u8; array_len()] in the input/output converter signatures and ascription; a consumer build therefore fails with E0425 instead of resolving myflat::array_len(). This is the same compile-time-namespace case as the skipped free const and Holder: the function does not belong in the Kotlin surface merely because its result determines an array length. Please include registry.functions in length_names and extend the regression with an undeclared const-fn call.
A length can also CALL a source `const fn` (`[u8; array_len()]`), and the map enumerated consts, structs and enums — so the callee stayed bare and the generated file failed with `E0425`. Adding functions would have fixed this case and left the same question open for the next item kind. The map is now built from `registry.item_origins`, the very map `origin_module` reads, so it covers every indexed item by construction: it cannot drift from the lookup it feeds, and a newly indexed kind needs no change here. That is the actual defect — enumerating kinds at a site that wanted "anything the source crate defines" — and it had already produced two misses (associated consts, then const fns). Nothing else can be caught by mistake: a length's paths are consts, the types owning them, and const fns, all of which are indexed items. A const generic would not be indexed, and generated code defines no locals in a length position. The regression covers all three shapes against one fixture, and none of the three owners is declared to JniGen — each is a compile-time namespace, not a boundary type. Verified consts-only leaves both the associated const and the const-fn call bare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Fixed in Root cause, not the instanceAdding The map is now built from let length_names = registry.item_origins.keys()
.map(|ident| (ident.to_string(), registry.origin_module(ident)…))
.collect();It covers every indexed item by construction, cannot drift from the lookup it feeds, and needs no revisiting when a new kind is indexed. Nothing gets caught by mistake. A length's paths are consts, the types owning them, and const fns — all indexed items. A const generic is not indexed, and generated code defines no locals in a length position, so the wider map has no false-positive surface. RegressionAll three shapes against one fixture, with none of the three owners declared to JniGen: pub const env: usize = 4; // free const, name collides with a local
impl Holder { pub const N: usize = 4; } // associated const
pub const fn array_len() -> usize { 4 } // const fn callVerified with the map narrowed back to consts-only: Standing backThat is four defects in this one visitor, all found by you. The first three I fixed as reported, which is exactly why there was a fourth: each fix addressed the instance and left the enumeration pattern that produced it. This one changes the pattern. 335 lib tests, |
| // twice. Iterating the origin map cannot drift from `origin_module` | ||
| // and needs no revisiting when a new item kind is indexed. | ||
| let length_names: std::collections::HashMap<String, syn::Path> = registry | ||
| .item_origins |
There was a problem hiding this comment.
item_origins is not actually the set of every indexed item: Registry::from_items only inserts into it when SourceLocation::crate_name is Some. Core explicitly supports origin-less hand-built streams and documents that adapters fall back to crate for them; the previous item-map loops retained those names and applied that fallback.
I reproduced the regression on 227d32b by changing only the new test fixture to let loc = SourceLocation::default() and expecting the documented crate:: prefix. The focused test then emits bare [u8; env], [u8; Holder::N], and [u8; array_len()] in converter code (while declared Blob correctly becomes crate::Blob) and fails all three qualification assertions; this also breaks if the generated file is included below the crate root. Please derive the name set from all indexed named items independently of whether an origin stamp exists, then use origin_module(...).unwrap_or_else(default_module) for the value. A registry-level named-item iterator would avoid returning to per-adapter kind enumeration; an origin-less variant of this regression would pin the fallback.
Deriving the name set from `item_origins` was wrong: that map holds only the items whose `SourceLocation::crate_name` was set. Core supports origin-less hand-built streams and documents `crate` as their module, so for those the map is EMPTY and every length emitted bare — a regression the previous kind-enumeration loops did not have, since they read the item maps and fell back to `default_module`. So the name set is now independent of origin stamps, and only the VALUE uses `origin_module(..).unwrap_or_else(default_module)`. The kind enumeration moves to core as `Registry::named_item_idents` rather than returning to the adapter. That was the actual defect two rounds ago — an adapter listing item kinds it does not own — and putting the list beside the maps means a new kind is added once, where it is introduced. Both invariants are pinned: the existing regression covers a stamped stream, a new one covers an origin-less stream and its `crate::` fallback. They discriminate — with the name set derived from `item_origins` again, the stamped test still passes and only the origin-less one fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Fixed in
ChangeTwo separate concerns, now separated:
The kind enumeration moves to core as RegressionBoth invariants pinned, and they discriminate: the existing test covers a stamped stream, the new 336 lib tests, |
| /// and a source crate may legally declare `pub const env: usize` — so a | ||
| /// whole-item expression pass would rewrite those locals to | ||
| /// `mycrate::env` even when restricted to registered const idents. An array | ||
| /// length cannot contain a local, which is what makes this scope safe. |
There was a problem hiding this comment.
This safety premise is not true once the length contains an inline const block: those blocks may bind locals, and the length visitor currently has no scope tracking. This is valid Rust (verified with rustc):
pub const fn array_len() -> usize { 4 }
pub struct Blob {
pub local: [u8; const {
let array_len = 3;
array_len
}],
}With array_len indexed but undeclared, adding this field to the new regression on 58e4d13 rewrites the tail expression to myflat::array_len, producing converter types like:
[u8; const { let array_len = 3; myflat::array_len }]That is a function item rather than the local usize, so the generated consumer no longer compiles (and even a same-typed collision could silently change the length). Please make the length visitor scope-aware for bindings introduced inside inline const/block expressions and leave locally bound paths untouched. Skipping inline consts wholesale would also miss legitimate source references inside them, so a regression should cover both a shadowing local and a source item used in the same kind of block.
The length qualifier rewrites a bare path to its origin module, which is
sound only while every path in the length names a source item. An inline
const block breaks that premise — it may bind locals:
[u8; const { let array_len = 3; array_len }]
`array_len` there is a LOCAL, and qualifying it produces
`myflat::array_len`, a function item where a `usize` was meant. A same-typed
collision would be worse: it would compile and silently change the length.
Scope tracking is the general answer and is not worth its machinery here —
this shape has no place in an FFI boundary type. The whole family is refused
instead (inline const, block, unsafe block, closure, async), naming the type
and the fix: hoist the value into a named `const` and the length becomes an
ordinary path.
A block that binds NOTHING is refused too, deliberately. "Binds nothing" is
exactly the judgement that needs the scope tracking to be trustworthy, so
accepting on it would reintroduce the premise this removes. That also
refuses legitimate source references inside such a block — the conservative
direction, and the same hoist fixes it.
The regression uses the shadowing case, so it pins the rejection rather than
the mere presence of a block. Without the guard the generator accepts the
input and emits the mis-qualified length.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Confirmed in Your analysis is right: the premise in that doc comment ("a local cannot occur in a type-level expression") is false for an inline const block, and qualifying a shadowing local into a source item is exactly the silent-wrongness this pass exists to avoid. The same-typed collision you mention is the worse case — it would compile and change the length. Why reject instead of scope-trackScope tracking is the general answer, and it means teaching a name-qualification pass the binding rules of an expression grammar — const LOCAL_LEN: usize = 3;
pub local: [u8; LOCAL_LEN], // an ordinary path againThe whole family goes: inline const, block, unsafe block, closure, async. A block that binds nothing is refused too, deliberately. "Binds nothing" is precisely the judgement that needs the scope tracking to be trustworthy, so accepting on it would smuggle the premise back in. That does refuse a legitimate source reference inside such a block, as you noted — the conservative direction, and the same hoist fixes it. If you'd rather have the general machinery, say so and I'll build it; I did not want to add a silent-failure surface on a code path that has now had five defects. Regression
337 lib tests, |
| // feature this crate does not enable, and the walk mutates nothing. | ||
| impl syn::visit_mut::VisitMut for FindScope { | ||
| fn visit_expr_mut(&mut self, e: &mut syn::Expr) { | ||
| // Every expression form that can introduce a binding. |
There was a problem hiding this comment.
This list is not every expression form that can introduce a binding. match arms bind patterns directly, and if let/while let do likewise; their nested bodies are not guaranteed to appear as an Expr::Block node seen by this matcher.
I verified this with valid Rust and then replaced only the new regression field on 0820084:
pub local: [u8; match 3 {
array_len => array_len,
}],where the indexed const fn array_len() already exists. cargo test -p prebindgen --lib array_length_opening_a_scope_is_rejected --offline then fails with test did not panic as expected. Generation proceeds and the arm-body path is subject to the same rewrite from local array_len to the source function.
Please reject pattern-binding forms too (Match, If/Let, and any supported loop forms). Given the intentionally conservative policy here, a whitelist of expression shapes proven not to introduce bindings may be safer than another blacklist: otherwise each omitted/new syn::Expr variant recreates the silent-misqualification risk. Add at least the match-arm case to the rejection regression.
The rejection listed the binding forms, and that list was incomplete:
`match` arms bind their patterns directly, with no `Expr::Block` node for the
matcher to see, and `if let` / `while let` / `for` do the same.
[u8; match 3 { array_len => array_len }]
`array_len` there is a LOCAL, and generation proceeded to rewrite it into the
indexed `const fn` of that name.
Adding the missing variants would leave the same hole for the next one, so
the check is inverted: only a literal, a path, a call, and const arithmetic
over those (`Paren`/`Group`/`Binary`/`Unary`/`Cast`) are accepted. Every
other `syn::Expr` — existing or newly added — is refused. The failure mode
becomes "a legitimate length is refused", which is loud and worked around by
hoisting the value into a named `const`, instead of "a local is silently
mistaken for a source item".
That inversion is the point: this is the third defect in the same code path
that came from enumerating the bad cases rather than the good ones.
The regression now covers the inline const block, a `match` arm, and an
`if let`, sharing one fixture. Verified the previous blacklist lets the
`match` case through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Fixed in
WhitelistAccepted: The failure mode flips from "a local is silently mistaken for a source item" to "a legitimate length is refused" — loud, and worked around by hoisting into a named Worth naming plainly: this is the third defect in this code path from enumerating the bad cases instead of the good ones. First the visitor scoped to the whole item, then the length-name map enumerated item kinds, now the rejection enumerated binding forms. Each time I fixed the instance and left the shape that produced it. Inverting the check is the part that should have happened two rounds ago. RegressionThree cases sharing one fixture — inline const block, Still a judgement call, flagging againRefusing is a deliberate departure from your original ask for scope-aware tracking, taken on the repo owner's instruction. A block that binds nothing is refused too, so a legitimate source reference inside one is collateral. If you'd prefer the real scope tracking, I'll build it — but I'd rather not add a silent-failure surface to a path with this history without you choosing it explicitly. 339 lib tests, |
|
|
||
| impl syn::visit_mut::VisitMut for QualifyLengthPaths<'_> { | ||
| fn visit_expr_path_mut(&mut self, ep: &mut syn::ExprPath) { | ||
| if ep.qself.is_none() && ep.path.leading_colon.is_none() { |
There was a problem hiding this comment.
The whitelist accepts every Expr::Path, but this branch explicitly skips paths with qself. That leaves a valid associated-const spelling accepted yet unqualified:
pub struct Holder { pub marker: u8 }
// source impl: `impl Holder { const N: usize = 4; }`
pub qualified_assoc: [u8; <Holder>::N],I verified the syntax with rustc, then added only this field to the existing qualification fixture on cab1390. The focused test generates [u8; <Holder>::N] unchanged in the input/output converter signatures and ascription (rather than [u8; <myflat::Holder>::N]), so a consumer build cannot resolve Holder in the generated crate.
Please either qualify the type inside qself with the same registry-wide origin map, or—consistent with the new conservative policy—reject Expr::Path when qself.is_some() during whitelist validation. The current combination silently accepts a form the qualifier declines to process. A regression using <Holder>::N would pin the chosen behavior; <Holder as Trait>::N deserves the same explicit decision.
|
Confirmed, and deliberately not fixed here — filed as #210 instead. Your finding is correct: the whitelist accepts every The reason for an issue rather than an eighth patch: this is the seventh defect in this code path, and the last one states the pattern outright. There are two independent walks over the length expression — a validator deciding what is accepted, and a rewriter deciding what it qualifies — with nothing tying them together. Every round has been the two disagreeing about one input:
Fixing #8 on its own — qualify #210 proposes collapsing the two walks into one fallible pass whose contract is " Your call whether #209 ships with the |
… extents to C (#210, first step of #211) (#212) * Lower array lengths in one frontend walk, not two adapter walks Array-length handling had two independent walks over the same expression: a whitelist in jnigen deciding what was ACCEPTED, and a rewriter deciding what it could QUALIFY. Nothing tied them together, so they drifted eight times (#210). The eighth was still open — the whitelist accepted every `Expr::Path`, the rewriter skipped paths with a `qself`, so pub qualified_assoc: [u8; <Holder>::N], was accepted and emitted verbatim into a crate where `Holder` is not in scope. Fixing that shape alone leaves the mechanism that produced #1-#8 intact. Both walks are replaced by one fallible lowering in a new `core::frontend`: lower_array_len(&Expr, &NameIndex) -> Result<ArrayLen, UnsupportedArrayLen> with the contract that `Ok` means the length was fully understood AND fully resolved. "Accepted" is now a consequence of lowering rather than a parallel judgement, so accepted-but-unqualified is not representable. `ArrayLen` is the one closed representation every consumer reads. It runs at INGEST, as pass 3 of `Registry::from_items`, where the name index is complete. So the decision is made before any adapter exists — both generators refuse the same input with the same message — and no emit-time length pass remains: `reject_unsupported_array_length`, `QualifyLengthPaths` and the `length_names` map are deleted, along with the comment block recording which defect each guard came from. The grammar narrows to a literal or a plain path. Const arithmetic (`[u8; A + 1]`, `[u8; A as usize]`) and `const fn` calls (`[u8; array_len()]`) leave the language; hoist the value into a named `const`. This is an intentional breaking change with a small blast radius — `[T; N]` crossing arrived in #209 and no released binding depends on it. `zenoh-flat`'s `[u8; ZENOH_ID_MAX_SIZE]`, the one real use, is a free-const path and is unaffected; regenerating zenoh-flat-jni changes converter names and one diagnostic string, nothing on the Kotlin surface. Three variants rather than the two #211 sketches: a path naming nothing the registry indexes (`usize::MAX`) is legal and must be emitted verbatim. That was a silent fallthrough in the old rewriter; `ExternalConst` makes it explicit, so lowering stays total instead of reintroducing a guess. Two shapes change behavior beyond the narrowing. A `qself` is now an explicit rejection naming the offending sub-expression — #210 asked for the decision to be made either way, and silent acceptance was the one option it ruled out. And `crate::MAX` now resolves like the bare `MAX`; previously its leading segment missed the name set and it was emitted as `crate::MAX`, which names the CONSUMER's crate. Stripping the source head is deliberately not the type-path rule, which reduces to the final segment and would collapse `myflat::Holder::N` to `N`, losing the owner of the associated const. Tests replace the eight characterization tests with a table: accepted spelling -> lowered value -> emitted spelling, and refused spelling -> reason. A new shape is a row. The two jnigen tests that only an end-to-end run can prove survive — that qualification reaches emitted code and does NOT touch a converter body's identically-named locals. The covertest fixture sizes `Arrays::bytes` by a `#[prebindgen]` const instead of a literal. That the covertest crate COMPILES is the check: a literal would prove nothing about resolving a path a different crate can name. `docs/source-language.md` writes down the accepted subset — #211 step 1 — and is explicit that only this row has moved into the frontend so far. Closes #210. First step of #211. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Resolve a length path by its item, not by its first segment Review finding on #212. `strip_source_head` dropped a `crate`/`self`/source-crate head and then assumed the next segment was the semantic anchor. That assumption holds only if accepted paths contain no intermediate modules, which was never stated and is not true: mod limits { #[prebindgen] pub const MAX: usize = 4; } pub bytes: [u8; crate::limits::MAX], `limits` is not indexed, so this became `ExternalConst { limits::MAX }` and the generated crate emitted an unqualified `limits::MAX` — unresolvable there, or worse, bound to a consumer-side module. It also contradicted the `ExternalConst` contract outright: the head was stripped BEFORE the path was classified, so a path documented as verbatim was not. The fix is the flat namespace. Prebindgen items are uniquely named and reachable as `<origin crate>::<bare name>`, so a module prefix inside the source crate carries no information — the bare name already identifies the item. Lowering becomes two ordered decisions: 1. Is the path source-relative? Its head is `crate`, `self`, a source module, or an indexed name. Everything else is external and is returned untouched. Classifying FIRST is what makes the verbatim guarantee true; it also keeps `other_crate::Holder::N` alone even though `Holder` names an indexed item, the same rule `normalize_type` applies to foreign type paths. 2. Which segment is the item? The leftmost that names one. Everything before it is module path and is replaced by the origin module; everything after is relative to the item and kept. So `MAX`, `crate::MAX`, `myflat::MAX`, `crate::limits::MAX` all lower to one value — which is the property that was missing, not just a missing branch. Leftmost, not rightmost: in `Holder::N` with a free const `N` also indexed, `Holder` is the anchor and `N` is its associated const. A `Leaf` item — a const or fn, through which nothing is reachable — is skipped when segments follow it, so a const sharing a module's name cannot capture the path. That is the only collision Rust permits, modules and types sharing one namespace but consts sitting in another, and it is a matrix row. A source-relative path naming no indexed item is now a hard error rather than silently verbatim: it claims a source item, so emitting it into a different crate is exactly the misinterpretation this pass exists to prevent. The bare spelling stays `ExternalConst` — indistinguishable from an external namespace — and the docs now say so instead of implying one rule. This makes explicit an invariant that was already load-bearing for TYPES: `normalize_type` reduces `crate::a::Foo` to `Foo` and emission qualifies it to `myflat::Foo`, so a nested item has always had to be re-exported at the source crate root. zenoh-flat already does — `ZENOH_ID_MAX_SIZE` lives in `base::config::zenoh_id` and `lib.rs` re-exports it. Verified the anchor scan is load-bearing by restoring the first-segment rule: the matrix fails. Generated output byte-identical; covertest PASS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * An array length is a number, not a path Rereview of #212 found a two-source provenance hole: one registry-wide name index, no origin for the item being lowered, so `crate::limits::MAX` in source A — with `MAX` unmarked there and marked in source B — anchored to B and silently changed the length from 4 to 8. It also found relative module paths to be irreducibly ambiguous without indexing modules, and the constructor unusable through the public facade. All three dissolve rather than get patched, once the requirement is stated properly: a length must be a KNOWN NUMBER. A generator runs in build.rs and cannot evaluate Rust, and a destination language that groups a small array into scalars needs the count literally — a Kotlin surface cannot reference a Rust const at all. So the grammar is an integer literal, or the BARE NAME of a `#[prebindgen]` const whose own initializer is an integer literal. The frontend reads the value and emits the number. That is a real narrowing, and it is the point: * module paths go, because a marked item is uniquely named and the bare name is its whole address — `crate::limits::MAX` only restated it, and `limits::MAX` was never distinguishable from a foreign crate path; * associated consts go, because prebindgen never captures `impl` blocks, so `Holder::N` could only ever have produced a path, never a number; * external paths go, and `usize::MAX` shows why — it is not even a fixed value; * an UNMARKED const is now a hard error rather than emitted verbatim. The generated crate sees only what the macro exposed, so this was never a qualification problem: the item does not exist downstream. Provenance is then a one-line rule instead of a lowering context: a bare name must be a const marked in the item's own source crate. Uniqueness holds across the marked namespace only, so without it the review's example still binds to the other crate's value; with it, that is unrepresentable. Origin-less streams are one anonymous crate and match trivially. Emitting the number is what removes the defect class rather than fixing it. There is no path left in generated code, so there is nothing to qualify, no namespace to get wrong, and no re-export invariant to rely on. A const length and the same number written literally become one type and one converter — which they always were in Rust. And a changed const now shows in the diff of a committed generated artifact, where echoing the name showed nothing at all. API surface (finding 3, my call): the lowering machinery is crate-private and the public frontend is the decided model plus its diagnostics. `NameIndex`, `ItemRole` and `ArrayLenResolver` are gone from the facade rather than completed; the entry point is `Registry::from_items`, and offering a second route to a partly-built model is what #211 exists to prevent. `Registry::named_item_idents`/`named_items` go with them — they existed only for the machinery this replaces. The restriction is on LENGTHS, not on consts: a `#[prebindgen]` const may still be computed however you like. Generated output is byte-identical to main apart from the new fixture const: `[u8; ARRAY_BYTES]` evaluates to the `[u8; 4]` that was there before. covertest PASS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * A type-keyed length table stores the number, not the spelling Review finding on 4880ffe. `Registry::array_lens` was keyed by `TypeKey` but stored `ArrayLen`, which records WHICH SPELLING produced it — and `TypeKey` has already collapsed the spellings. Given pub a: [u8; A], // A = 4 pub b: [u8; B], // B = 4 pub literal: [u8; 4], all three are one key, so the stored `Const { name }` was whichever occurrence the iteration reached last: order dependent, and false for the other two either way. No consumer reads the name today, so nothing drifted — it was a latent nondeterminism and a claim the table could not honour. Three identities were being conflated, and the fix is to keep them apart rather than to drop the resolution: * const identity — `A` is `4` in crate X — the const index; * type semantics — `[u8; 4]` — the type table; * source-use provenance — field `S::a` was written `[u8; A]` — per occurrence, belonging to a per-use model that does not exist yet (#211's `SourceModel`). So the type-keyed table holds `usize` and `Registry::array_len` returns one. `ArrayLen` keeps the spelling, because at the occurrence level that is true, and becomes crate-private: there is no type-keyed table it could be handed out through honestly, and nothing public produced it any more. `equal_lengths_collapse_to_one_typed_entry` is the regression: three fields, two const-spelled with equal values, one type between them. It asserts both halves — the occurrences stay distinguishable, the type does not — so the layering is pinned rather than the current storage choice. Generated output byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * The spelling of a length is discarded, and says so Review finding on 961be86: the previous test claimed occurrences "stay distinguishable", but that was only true inside the lowering call. After `Registry::from_items` the item types are rewritten to the literal and the occurrence vector is dropped, so no adapter can tell that `S::a` wrote `A`, `S::b` wrote `B`, and `S::literal` wrote `4`. The model was advertising provenance that did not survive the boundary it was documented at. The review offered two coherent ends: build the per-use record now, or declare the spelling semantically irrelevant. Taking the second, explicitly. `ArrayLen` is deleted; lowering returns `usize`. It carried a name that nothing read — a successful lowering raises no diagnostic, and `UnsupportedArrayLen` already renders the offending expression — so the enum existed only to make the overclaim expressible. Why the policy rather than the record: `[u8; A]` and `[u8; B]` with `A == B == 4` are one Rust type, one `TypeKey` and one converter. An adapter rendering them differently would need two destination representations for a single Rust type, which the type-keyed converter table cannot express — so the capability is incoherent at this layer even if the provenance were carried. Building a per-use record now would also mean inventing the "stable use identity" that is precisely `SourceModel`'s design work, one construct at a time, which is the parallel structure #211 exists to remove. The cost is stated rather than hidden: a C header cannot echo `uint8_t x[MAX_SIZE]`, only `uint8_t x[16]`. If that is wanted, provenance arrives on the use site in `SourceModel` — never on a type-keyed table, where three occupants of one key cannot be told apart by the key. The regression now inspects the state AFTER `Registry::from_items`, as asked, and pins both halves: all three fields are `[u8; 4]` sharing one type-keyed row with value 4, and the const names appear nowhere in the model. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * A C header can spell an array extent by name `uint8_t tag[MARKER_TAG_LEN]`, not `uint8_t tag[4]`. A symbolic extent is part of the C API's meaning: it makes changing the size one edit instead of a hunt through literals. 32370af had discarded the spelling, so that fact could not reach cbindgen at all. The obvious patch — stash the original `syn::Type` and let cbindgen re-read it — is what #211 forbids twice over: adapters do not parse captured source, and emitters do not recover semantic facts by re-reading it. So the fact goes in the IR, and this begins `SourceModel` (#211 step 2) rather than building a side channel that would have to be deleted again. `core::frontend::model` adds a closed, language-neutral `SourceType`. Lowering is TOTAL over the grammar in docs/source-language.md — a form it cannot lower is a frontend error, the same acceptance-is-lowering contract array lengths already had. An array node carries `ArrayExtent { value, source }`: the value is the semantic length that keeps `[u8; A]` and `[u8; 4]` one type and one converter, the source is which const the use site named. Both halves have consumers, and they are different ones. The model is the source of truth and `syn` is a projection: pass 3 lowers every struct field and writes `to_syn()` back into the `syn::ItemStruct`, so there are never two representations to disagree. `TypeKey` stays numeric, which is why all of jnigen and most of cbindgen are untouched — covertest PASS, generated Kotlin and Rust byte-identical. Scope is bounded on purpose. Types: complete. Items: structs only, the one surface an adapter consumes as source; functions and enums keep their `syn` items, which #211 step 4 sanctions while adapters migrate. Adapters: cbindgen's struct-field path only. Its per-field CLASSIFICATION still runs on `to_syn()` — `SourceType` is already the answer to `is_scalar`/`is_string`/`is_vec`, but deleting those duplicates is F5/F6, not this change. Two things blocked the header besides the missing fact, both verified rather than assumed: * cbindgen had no array support at all, so `[T; N]` in a data-struct field panicked. `c_field_wire` now accepts an array of non-`bool` scalars: `[u8; N]` is already `#[repr(C)]`-compatible, so it is its own wire and the field copies with no conversion. `[bool; N]` stays refused — its domain is `0`/`1` and a mirror is reinterpreted wholesale with no per-element hook, the same hazard `restricted_validity_field` documents. * consts never reached the header. `on_const` aliased them to `= perftest_flat::ARRAY_BYTES`, a path cbindgen cannot evaluate, so it emitted no `#define`. A probe pinned this down exactly: with a literal it emits `#define MARKER_TAG_LEN 4` and `uint8_t tag[MARKER_TAG_LEN]`; with the alias it emits the struct referencing an UNDEFINED symbol. So the literal is not cosmetic — without it the feature produces a header that does not compile. Only extent consts change; the rest keep the alias. The exercise is in example-cbindgen because `smoke-asan.sh` is the only place CI compiles a generated header. `Marker { tag: [u8; MARKER_TAG_LEN], weight }` round-trips by value in smoke.c, which uses the macro as a bound — so the test COMPILING is most of the proof, since a missing `#define` is a compile error. The x86_64 goldens are derived: the substitution from the aarch64 pair was first verified to reproduce the committed x86_64 pair exactly at HEAD, then applied. CI on x86_64 is the oracle. `equal_lengths_collapse_to_one_type_after_ingest` now pins both halves for real. The second half — that the model still reports `A`, `B` and a literal per field — was the assertion the previous review correctly called untrue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * The projection preserves type identity Review finding on 21ab04a. Pass 3 writes `field.ty = ty.to_syn()`, which made the projection a semantic rewrite in the live pipeline — while it could not reconstruct five forms the grammar accepts: &'static Foo -> &Foo Foo<'static> -> Foo (Foo,) -> (Foo) <T as Trait>::Assoc -> Trait::Assoc foreign::Option<u8> -> Option<u8> Each changes `TypeKey`, so each changes converter selection and the emitted field type. The lifetime losses contradict this repo's own rule that `Foo<'static>` is not `Foo`, which `ptr_class!(ZKeyExpr<'static>)` relies on. Three of the five turn out to be LANGUAGE questions, so the fix narrows the grammar rather than growing the model: * Tuples are not in the language. Verified: every `Type::Tuple` site in both adapters is the unit case or a generic walk — no adapter has ever lowered a non-empty tuple. Refusing deletes the 1-tuple bug instead of fixing it, and turns a late "unresolved type" into a precise frontend error naming the type. * Associated types are refused. `#[prebindgen]` never captures `impl` blocks, so what `<T as Trait>::Assoc` resolves to is unknowable here; carrying the spelling would only move the failure downstream. `Named` then needs no `qself`, which removes that reconstruction path entirely. * `foreign::Option` was a DETECTION bug, not a fidelity one: builtins matched on the last segment alone. They now require a bare single-segment path, which is exactly what `normalize_type` already guarantees — it reduces the genuine std spellings at ingest and deliberately leaves unknown crate paths alone. A foreign type that merely shares a name stays foreign. The remaining two are fidelity, and lifetimes are kept the way they actually behave: part of a type's NAME, never structure, because they mean nothing to a destination language. `Named` keeps its path as identity with the last segment's arguments split out in SOURCE ORDER, lifetimes included, so `Foo<'a, T>` comes back exactly; nested types stay canonical, so an extent inside a generic still projects numerically and `TypeKey` stays right. `Ref` gains a lifetime — no struct field needs one today, but `&'static Encoding` is a real zenoh-flat return, and the variant has to be right before signatures are modeled. The old `to_syn_round_trips` could not catch any of this: it projected an already-projected type, so it only proved a lossy function idempotent. It is replaced by comparing `TypeKey` of the NORMALIZED ORIGINAL against `TypeKey` of its projection, over every accepted form including all five above. Verified it discriminates by restoring two of the bugs: each fails naming its own symptom. Generated output is byte-identical in-repo, and the sibling zenoh-flat-jni regenerates identically to 21ab04a — none of the five occurs in a real struct field, so a correct fix moves nothing. covertest PASS, smoke.c PASS. Also fixes the contract text the review flagged: `frontend.rs`'s "Scope today" and `docs/source-language.md`'s "Read this first" both still claimed only array lengths had moved. The Types table now states the tuple and qself refusals, and records that they bind only in MODELED positions — struct fields today — which is the honest shape of a partial migration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Turbofish and a trailing generic comma are not identity Review finding on 13978e7: `Wrapper::<u8>` and `Wrapper<u8,>` lost their punctuation through `to_syn()`, and `TypeKey` — whose identity is the normalized token string — treated the result as a different type. The damage is worse than a mismatched key. Modeled struct fields are rewritten through `to_syn()` and function signatures are not, so during the migration one Rust type would hold the old key in a signature and the new key in a field, splitting converter lookup by POSITION. Fixed where the reviewer said it belongs: at the declared canonicalization boundary. `normalize_type` now drops the turbofish and a trailing comma, so every position agrees and no consumer has to. Carrying the punctuation in `SourceType` was the alternative, and it is the wrong one — the spellings mean the same type, so preserving the difference only spreads it. That also keeps reconstruction from silently defining canonicalization, which was the reviewer's real point: `to_syn()` already emitted the bare form, so it was deciding by accident what `normalize_type` should decide on purpose. Verified both tests are load-bearing by removing the normalization: the types_util test fails `"Wrapper :: < u8 >" != "Wrapper < u8 >"` and the identity matrix fails `Wrapper::<u8> lost identity`, which is exactly what the review reproduced. Lifetimes stay identity — `Foo::<'static,>` collapses to `Foo<'static>`, never to `Foo`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Close the callback gate, and say which refusals are temporary Review finding on a3cf2af, and this one was LIVE rather than latent. `extract_fn_trait_args` is the acceptance gate for every callback — 14 call sites across core resolution and both adapters — and it read `p.inputs` while never looking at `p.output` or the bound's `lifetimes`. So impl Fn(u8) -> u16 + Send + Sync + 'static was accepted, planned from its arguments alone, and its result dropped: every callback wire is void-shaped, C's `call` having no return and jnigen's `run` returning void. The test that now refuses it reports, without the fix, exactly what was happening — `accepted as Callback { args: [Scalar(U8)] }`. Refusals now come in two kinds, because a bare "no" leaves the author guessing whether to redesign or to wait, and those are opposite responses: * RESERVED — the language intends it, the machinery does not exist yet. A callback returning a value is reserved, and the diagnostic names issue #216, which carries the design that actually blocks it: both adapters have a case where the foreign side yields no value (a null `call` pointer, a throwing Kotlin callback), and with a return type there is nothing to swallow to. A callback returning a callback is reserved too. * UNSUPPORTED — it cannot work here. A higher-ranked binder is the case: no FFI boundary can be generic over a lifetime, so no adapter could ever carry one. The gate becomes `extract_fn_trait_sig -> Result<_, CallbackReject>` with `extract_fn_trait_args` a thin `.ok()` wrapper, so the 13 shape-query callers are untouched and ONE function still decides. A separate "why did it fail" helper beside an Option-returning gate would have been the two-authority drift this PR exists to remove. `ScanError::DisallowedImplTrait` and the model's `UnsupportedTypeReason` both carry that one reason rather than restating it. Three further spellings were one type with different keys — the split-by-position bug fixed for turbofish in a3cf2af, reappearing in `Fn`'s parenthesized argument list and its bound list. They canonicalize at the same boundary, by the rule settled last round: a trailing input comma, an explicitly-spelled `-> ()`, and bound order (traits sorted, lifetime bounds last so the result stays valid Rust). Since every accepted callback is therefore unit-returning with a fixed bound order, `SourceType::Callback` needs no output field and its projection stays exact. Verified both new rules are load-bearing by reverting each: the refusal matrix accepts the non-unit callback again, and the normalization test reports `Sync + Send` against `Send + Sync`. Generated output byte-identical in-repo, and the sibling zenoh-flat-jni regenerates identically to a3cf2af. covertest PASS, smoke.c PASS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Fix normalization idempotence, a false HRTB claim, and a layering inversion Three findings on ca21e16, all correct. **Idempotence.** `visit_path_arguments_mut` tested whether the `Fn` return was `()` BEFORE calling the recursion that unwraps `Paren`, so `impl Fn(u8) -> (())` needed two passes to reach `impl Fn(u8)`. Items are normalized at ingest and `TypeKey::from_type` normalizes again, while a directly built key normalizes once — so a key could depend on how many passes its input had had. The recursion moves to the top, matching `visit_type_impl_trait_mut`, which already had the right order. The regression is the general property, not the instance: `normalize_is_idempotent` asserts `canon(canon(x)) == canon(x)` over every spelling the suite exercises, because the way this breaks is a rule reading a node the recursion has not reached yet, and that mistake belongs to no particular rule. **The HRTB claim was wrong.** `ca21e16` refused an explicit `for<'a>` binder as definitively unsupported, reasoning that no FFI boundary can be generic over a lifetime. The counter-example is decisive: `impl Fn(&u8)` IS higher-ranked — it desugars to `impl for<'a> Fn(&'a u8)` — and the two are mutually substitutable. The binder constrains the Rust closure; it is not carried on any wire. So the accepted spelling and the refused one are the same type, which makes this the last two-spellings-one-type case rather than an impossibility. Reclassified to RESERVED with the real reason: the canonicalization is not written, and it has to be exact, since elision gives each elided input lifetime its own fresh binder — `for<'a> Fn(&'a u8, &'a u8)` is NOT `Fn(&u8, &u8)`. Issue #222 carries that rule. The diagnostic now points at the elided form, which is accepted and means the same thing. **Core depended on an adapter.** `extract_fn_trait_sig` called `jnigen::util::is_unit` — a language-neutral source rule reaching into one adapter, the inversion #211 exists to prevent. Core's own `is_unit` is ungated and jnigen's is DELETED rather than re-exported: it had exactly one other caller, and two names for one predicate is what let the inversion happen unnoticed. Verified the idempotence fix is load-bearing by moving the recursion back — both the general assertion and the `-> (())` row fail, reproducing the review exactly. Generated output byte-identical in-repo and in the sibling zenoh-flat-jni. Builds clean with and without default features. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * The HRTB fix-it is only exact when elision preserves the binding Follow-up on 2b84e02. That commit corrected the HRTB *classification* but left the advice unconditional: "write the elided form instead (`Fn(&T)`, which means exactly the same thing)". True for `for<'a> Fn(&'a T)`, false for the rest of the refused class — the enum's own doc gives the counterexample two lines above. Elision binds each input separately, so `Fn(&T, &T)` is `for<'a, 'b> Fn(&'a T, &'b T)`; an author whose signature ties two inputs to one lifetime was being told to make a semantic change and told it was exact. The message now states the condition: exact when every bound lifetime is used once, and no accepted spelling yet when one is used twice. `source-language.md` carried the same unconditional sentence and gets the same treatment. The acceptance matrix still labelled the row UNSUPPORTED and repeated the retracted "no FFI boundary can be generic over a lifetime" claim, so the executable document disagreed with both the enum and the guide. Relabelled RESERVED with the actual reason. Checked that no copy of the retracted claim survives anywhere in the tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Supersedes #207, whose equality work is carried forward here (commits preserved).
Why
A
value_class!type crossed as the raw memory image of the Rust struct. That representation produced three defects in one review cycle and carried a latent unsoundness:ByteArray, which compares by identity, soZenohId(b) != ZenohId(b)(the original report).equals/hashCodemembers on a value class through 2.2.21 (measured, including-Xvalue-classes), so the wrapper had to become a real class — losing the zero-allocation property that was the entire point.read_unaligned::<T>()-ed caller-supplied bytes after a length check only.Copynever implied every bit pattern was a validT, and no trait asserts that:struct S { flag: bool }plus a byte of2was UB.repr(Rust)struct's memory image, padding included, for a layout with no stability guarantee.The blob existed for exactly one reason: the resolver could not handle a fixed-size array field.
ZenohId { bytes: [u8; ZENOH_ID_MAX_SIZE] }failed withUnresolved { key: TypeKey("[u8 ; 16]") }in both directions.What
1.
[T; N]crosses as the matching Kotlin primitive array. Table-driven off the existingJniPrimscalars —[u8; N]→ByteArray,[i64; N]→LongArray, and so on for all eight — bulk-copied throughset_*_array_region/get_*_array_region, boxing nothing. Wider unsigned elements carry raw bits in the signed array, matching the existing scalar rule (u64already crosses as a rawjlong); Kotlin'sULongArrayis@ExperimentalUnsignedTypesand would push an opt-in onto every consumer of a shared tier.Nis never needed at generation time — it is often a const path, not a literal — so the decode leans onTryFrom<&[T]> for [T; N]. Thattry_intois the length check. Elements convert by cast, never transmute, which is what removes the UB.2. The blob is deleted — declarator,
ProjectionKind::ValueBlob,WrapKind::Blob,write_value_blobs, the__assert_copyguard, ~50 arms across 17 files.ValueClassDeclwas a strict subset ofDataClassDecl, so both call sites migrated by changing the declarator alone.3. Content equality (from #207) is carried forward and widened — every Kotlin primitive array compares by identity, not just
ByteArray.Found by doing it, not assumed
QualifyEmittedTypesimplemented onlyvisit_type_path_mut, but an array length is an expression path — so[u8; ZENOH_ID_MAX_SIZE]emitted a const that wasn't in scope. The newvisit_expr_path_mutis restricted to registry-indexed const idents; that restriction is load-bearing, since expression paths also cover every local in a converter body (v,env).missing lifetime specifier→unsupported wire JShortArray→NoSuchMethodError: run). Now one table each inwire_access.is_leaf_vec_element's doc contradicted its body (claimed opaque handles excluded;cfg.is_opaque()includes them). Pre-existing, corrected in passing.Coverage
Arrayscarries one field per primitive family member plus[u64; 2]pinning the raw-bits rule, round-tripped both directions with per-element value checks — an equality-only check between two echoes would survive a cast bug.BlobValuekeeps the array-backed equality cases.Stampbecomes the negative case: a scalar-only data class the content operators must leave alone.The two binding-error sections that sourced their failure from the blob's byte-length guard are re-pointed at the fixed-size-array length guard, its direct successor.
Cost, measured
ZenohId's generated Kotlin is byte-identical apart from the now-false blob KDoc — the check that this removal is API-neutral.One cold-path regression, tracked in #208: a bare
ZenohIdreturn now goes through thefromPartsbuilder upcall where the blob returned its wire directly. Nested uses (EntityGlobalId.zidinsideSourceInfo— the sample callback's hot path) ride their parent's singlefromPartsand are unaffected. #208 also covers packing a small array into scalar slots, which would retire the equality machinery for such types entirely.Verification
334 lib tests ·
PASS - 46 sections· fmt + clippy clean. Downstream ZettaScaleLabs/zenoh-flat-jni#16 builds against this with 18/18 JVM tests.🤖 Generated with Claude Code