Skip to content

Remove the value blob; fixed-size arrays cross as Kotlin primitive arrays - #209

Merged
milyin merged 12 commits into
mainfrom
jnigen-drop-value-blob
Jul 27, 2026
Merged

Remove the value blob; fixed-size arrays cross as Kotlin primitive arrays#209
milyin merged 12 commits into
mainfrom
jnigen-drop-value-blob

Conversation

@milyin

@milyin milyin commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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:

  • Unequal — it surfaced as a ByteArray, which compares by identity, so ZenohId(b) != ZenohId(b) (the original report).
  • Unfixable as a value class — Kotlin reserves equals/hashCode members 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.
  • 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 no trait asserts that: struct S { flag: bool } plus a byte of 2 was UB.
  • Layout-dependent — the JVM held a 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 with Unresolved { key: TypeKey("[u8 ; 16]") } in both directions.

What

1. [T; N] crosses as the matching Kotlin primitive array. Table-driven off the existing JniPrim scalars — [u8; N]ByteArray, [i64; N]LongArray, and so on for all eight — bulk-copied through set_*_array_region / get_*_array_region, boxing nothing. Wider unsigned elements carry raw bits in the signed array, matching the existing scalar rule (u64 already crosses as a raw jlong); Kotlin's ULongArray is @ExperimentalUnsignedTypes and would push an opt-in onto every consumer of a shared tier.

N is never needed at generation time — it is often a const path, not a literal — so the decode leans on TryFrom<&[T]> for [T; N]. That try_into is 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_copy guard, ~50 arms across 17 files. ValueClassDecl was a strict subset of DataClassDecl, 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

  • 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 wasn't in scope. The new visit_expr_path_mut is restricted to registry-indexed const idents; that restriction is load-bearing, since expression paths also cover every local in a converter body (v, env).
  • Four tables independently encoded "which wires are object-shaped" / "which Kotlin types are arrays". Adding seven wires needed all four, and I found that by breaking three in turn (missing lifetime specifierunsupported wire JShortArrayNoSuchMethodError: run). Now one table each in wire_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

Arrays carries 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. BlobValue keeps the array-backed equality cases. Stamp becomes 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 ZenohId return now goes through the fromParts builder upcall where the blob returned its wire directly. Nested uses (EntityGlobalId.zid inside SourceInfo — the sample callback's hot path) ride their parent's single fromParts and 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

milyin and others added 5 commits July 27, 2026 13:23
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) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@milyin

milyin commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Confirmed and fixed in 41ad36b. Your diagnosis was exact, including the part I got wrong in reasoning.

What I got wrong

I did consider this hazard — the commit message says "expression paths are also every local in a converter body, and qualifying those indiscriminately would rewrite v and env" — and concluded that restricting to registry-indexed const idents closed it. It doesn't, for the reason you found: a source crate can legally declare a const named env. The restriction narrows which idents get rewritten; it does nothing about a const whose name collides with a generator-owned local. Reasoning where I should have tested.

Fix

Exactly as you proposed — visit_type_array_mut visiting the element normally and running a small QualifyConstPaths visitor over arr.len alone. An array length is the only place a const legitimately appears inside a type, and a local can never appear there, which is what makes the scope safe rather than merely narrower.

Why no test caught it

Because the corpus contained only consts that follow Rust naming convention:

fixture array length collides with a local?
covertest Arrays [u8; 4], [i16; 2], … no const at all
zenoh-flat ZenohId [u8; ZENOH_ID_MAX_SIZE] SCREAMING_SNAKE — never a local

So the real downstream case exercised the qualification path and passed, and nothing exercised the collision. Triggering it needs a lowercase const, which requires #[allow(non_upper_case_globals)] — unusual, but legal, and the generator has no business breaking on it.

Regression

array_length_const_is_qualified_without_touching_locals names its const env on purpose and asserts in both directions:

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 (myflat::env, ×10 and myflat::env. ×4 in converter bodies).

335 lib tests, PASS - 46 sections, fmt/clippy clean.


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 {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@milyin

milyin commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Confirmed and fixed in 205bba9.

The gap

You'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:

length segments looked up in result
[u8; MAX] 1 — a const const_names myflat::MAX
[u8; Holder::N] 2+ — leading segment is a TYPE source_names myflat::Holder::N

Only the leading segment is rewritten; ::N stays relative to the type that owns it, so the result is never myflat::Holder::myflat::N (asserted).

One thing worth recording, because my first assertion was wrong

I 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 —

fixed-size array decode: `[u8;Holder::N]` expects a different length

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.

Regression

Extended 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 env local-collision case from your previous comment. Verified it fails with the leading-segment lookup removed (Result<[u8;Holder::N] present, qualified form absent) before keeping it.

335 lib tests, PASS - 46 sections, fmt/clippy clean.

Boundary I did not cross

Qualification uses source_names, so it resolves when the owning type is declared to JniGen — same condition under which a bare Holder in type position resolves today. An associated const on an undeclared type stays unqualified. That's consistent rather than special-cased, but say the word if you'd rather it fall back to registry.origin_module for any indexed item.

milyin added a commit that referenced this pull request Jul 27, 2026
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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@milyin

milyin commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Fixed in c103958. You're right, and your framing is better than the one I offered.

I defended the old behavior as "consistent — same condition under which a bare Holder in type position resolves". That comparison was wrong: a type in field position is a boundary type and should need declaring, whereas Holder here is only the Rust namespace owning a compile-time length. Nothing about it crosses. And as you point out it was not even internally consistent — the free-const branch one line above already qualifies any indexed const, declared or not.

Change

Both halves of a length expression now read one registry-wide map (length_names: indexed consts + structs + enums → origin_module), so:

length owner must be declared?
[u8; MAX] no (unchanged)
[u8; Holder::N] no (was: yes)

QualifyConstPathsQualifyLengthPaths since it is no longer const-specific. The leading-segment rule is untouched: whichever shape the path has, the first segment carries the origin module and everything after stays relative to it.

Regression

Holder is now left undeclared, so the boundary is pinned rather than incidentally satisfied. Verified it fails with the registry-wide lookup removed, reproducing your exact symptom — bare Result<[u8; Holder::N] in the converter signature.

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, PASS - 46 sections, fmt/clippy clean.

for ident in registry.structs.keys() {
add_length_name(ident);
}
for ident in registry.enums.keys() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@milyin

milyin commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 227d32b.

Root cause, not the instance

Adding registry.functions would have fixed your case and left the identical question open for the next item kind. The real defect is that this site enumerated item kinds when what it wanted was "anything the source crate defines" — and that enumeration had already missed twice before you found this one (associated consts, then const fns).

The map is now built from registry.item_origins — the very map origin_module reads:

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.

Regression

All 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 call

Verified with the map narrowed back to consts-only: Holder::N and array_len() both emit bare while myflat::env still qualifies — so the test discriminates the three shapes rather than passing on one of them.

Standing back

That 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, PASS - 46 sections, fmt/clippy clean.

// 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@milyin

milyin commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 58e4d13. You're right, and this one is worse than the previous rounds: I introduced it while claiming to fix the class.

item_origins is populated only when SourceLocation::crate_name is Some, so for an origin-less stream the map is empty and every length emitted bare. The kind-enumeration loops I replaced did not have that bug — they read the item maps and fell back to default_module. I asserted "covers every indexed item by construction" without checking how the map I switched to is built, and traded a miss for a regression.

Change

Two separate concerns, now separated:

  • name set — every named item the registry indexes, independent of origin stamps;
  • valueorigin_module(..).unwrap_or_else(default_module), which is what supplies the documented crate:: fallback.

The kind enumeration moves to core as Registry::named_item_idents() rather than back into the adapter. That was the real defect two rounds ago — an adapter listing item kinds it doesn't own — so the list now sits beside the maps, where a new kind is added once at the point it is introduced.

Regression

Both invariants pinned, and they discriminate: the existing test covers a stamped stream, the new array_length_qualification_falls_back_to_crate_without_an_origin covers an origin-less one. Re-deriving the name set from item_origins leaves the stamped test passing and fails only the origin-less one — exactly your reproduction.

336 lib tests, PASS - 46 sections, fmt/clippy clean.

/// 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@milyin

milyin commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Confirmed in 0820084 — but rejected rather than scope-tracked, which is a deliberate departure from what you asked for.

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-track

Scope tracking is the general answer, and it means teaching a name-qualification pass the binding rules of an expression grammar — let, patterns, shadowing, nested blocks. That is a real amount of machinery for a shape that has no business in an FFI boundary struct, and it is machinery whose bugs would be silent rather than loud. Refusing costs nothing a source crate can't trivially work around:

const LOCAL_LEN: usize = 3;
pub local: [u8; LOCAL_LEN],   // an ordinary path again

The 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

array_length_opening_a_scope_is_rejected uses your shadowing case, so it pins the rejection rather than the mere presence of a block. Verified with the guard removed: the generator accepts the input and emits the mis-qualified length, so the test discriminates.

337 lib tests, PASS - 46 sections, fmt/clippy clean.

// 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@milyin

milyin commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Fixed in cab1390, and I took the whitelist — you were right that another blacklist just recreates the risk.

match arms bind their patterns with no Expr::Block node for the matcher to see, so my list missed them; if let / while let / for are the same shape. Reproduced your case exactly: generation proceeded and rewrote the arm-body array_len into the indexed const fn.

Whitelist

Accepted: Lit, Path, Call, and const arithmetic over those (Paren, Group, Binary, Unary, Cast) — which is every form the three supported length shapes actually use (4, MAX, Holder::N, array_len(), A + 1, X as usize). Every other syn::Expr, existing or newly added, is refused.

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 const.

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.

Regression

Three cases sharing one fixture — inline const block, match arm, if let — each shadowing the indexed array_len. Verified the previous blacklist lets the match case through (test did not panic as expected), so they discriminate rather than all passing for the same reason.

Still a judgement call, flagging again

Refusing 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, PASS - 46 sections, fmt/clippy clean.


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() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@milyin

milyin commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Confirmed, and deliberately not fixed here — filed as #210 instead.

Your finding is correct: the whitelist accepts every Expr::Path while QualifyLengthPaths skips qself, so <Holder>::N is accepted and then silently left unqualified. <Holder as Trait>::N is the same shape.

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:

# Symptom Which walk was wrong
1 locals rewritten (env) rewriter scoped too widely
2 Holder::N unqualified rewriter: single-segment only
3 undeclared owner unqualified rewriter read the declared surface
4 array_len() unqualified name set enumerated item kinds
5 origin-less streams unqualified name set omitted them
6 const { let … } mis-qualified validator's premise was false
7 match / if let mis-qualified validator enumerated binding forms
8 <Holder>::N accepted, unqualified validator accepts what the rewriter declines

Fixing #8 on its own — qualify qself, or reject it — leaves the mechanism that produced #1#8 in place, and I have now demonstrated seven times that I will not spot the next instance by inspection.

#210 proposes collapsing the two walks into one fallible pass whose contract is "Ok only if every path in the length was resolved and rewritten", so acceptance becomes a consequence of qualification instead of a parallel judgement. It also carries the qself decision, a table-driven test over length shapes, and a note to revisit the conservative-rejection policy adopted here under time pressure — with a single walk that reports precisely what it could not resolve, real scope tracking becomes tractable and the collateral refusal of binding-free blocks could go away.

Your call whether #209 ships with the qself hole open (it is a silent wrong-output case, so there is a reasonable argument for a one-line rejection here and the restructure in #210) or waits. Happy to do either — I did not want to make that call unilaterally after this many rounds.

@milyin
milyin merged commit 9eabf37 into main Jul 27, 2026
4 checks passed
milyin added a commit that referenced this pull request Jul 28, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant