Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions .scala-steward.conf
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
# jackson 2.22.0 regressed the @JsonIgnoreProperties case-insensitive fix
# (CVE-2026-54515, dependabot alert #7). 2.21.5 is patched; the re-fix lands
# in 2.22.1 (unreleased). Keep both artifacts on the 2.21.x series and lift
# this pin together with the build.sbt comment once 2.22.1 is on Central.
updates.pin = [
{ groupId = "com.fasterxml.jackson.core", artifactId = "jackson-core", version = "2.21." },
{ groupId = "com.fasterxml.jackson.core", artifactId = "jackson-databind", version = "2.21." }
# (CVE-2026-54515, dependabot alert #7). 2.22.1 carries the re-fix and is what
# apache-avro 1.12.2's parent BOM resolves, so the build is already past it.
#
# There is deliberately NO `updates.pin` here. A pin like `version = "2.22."`
# is a SERIES LOCK, not a floor: it would block Steward from ever proposing
# 2.23.x, and it matches 2.22.0 anyway so it does not even express "never
# 2.22.0". The previous `"2.21."` pin is the proof of that failure mode —
# 2.22.1 shipped OSV-clean and required by avro 1.12.2, yet the build sat on
# 2.21.5 until a human edited the pin by hand. Steward never proposes a
# version below the current one, so 2.22.0 is already unreachable from 2.22.1
# and a pin buys nothing but the stranding.
#
# `updates.ignore` below is the belt-and-braces form: it names the single bad
# version instead of locking a series, so 2.23.x and beyond still flow.
updates.ignore = [
{ groupId = "com.fasterxml.jackson.core", artifactId = "jackson-core", version = "2.22.0" },
{ groupId = "com.fasterxml.jackson.core", artifactId = "jackson-databind", version = "2.22.0" }
]
60 changes: 60 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
costs one allocation per schema rather than one per lookup and repeated lookups still hand back
the same instance.

### Added

- **`.fields(...)` no longer stops at 22 selectors** (#96): the macro-synthesised
`NamedTuple` focus no longer has a *spelling* ceiling. Up to 22 selectors the value tuple is spelled
`scala.TupleN` and hearth builds it with that tuple's constructor; at 23 and above the value
tuple *is* a `*:` cons chain, and hearth 0.4.2 builds that through `Tuple.fromArray`. Before,
hearth 0.4.0 called the cons chain's primary constructor — which takes no value parameters — so
every 23+-selector `.fields` failed to compile with `wrong number of arguments at inlining …
expected: 0, found: N`. Verified end-to-end (derive, encode, decode, positional read-back) at
arity 23 and at arity 40 with mixed field types, since the `Tuple.fromArray` path boxes every
element to `Object`. Both spellings remain load-bearing: hearth's sub-23 branch still cannot
build a cons chain, so `MacroSelectors.tupleTypeOf` keeps emitting `TupleN` below 23
permanently. **Two ceilings remain and are now documented rather than glossed:** 254 selectors
is hard and permanent (`.fields` selects from a case class, and the JVM caps a parameter list at
254 slots — a 255-field case class does not compile at all), and well below that the binding
limit is the compiler thread's stack, since the derivation recurses per field. Measured on a
full-cover probe, varying only `-Xss`: 1m (the JVM default) derives 32 and overflows at 36; 2m
derives 66; 4m (sbt's launcher default) derives 150; 8m — this repo's `.jvmopts`, and the only
reason its own suites reach 254 — derives 254. A downstream build inherits none of that, because
the published artifact cannot carry an `-Xss`, so a wide `.fields` may need `-Xss` raised in the
*consumer's* build.

### Changed

- **Dependency bump**: hearth `0.4.0` → `0.4.2` and kindlings-{avro,cats,circe}-derivation
`0.3.0` → `0.3.2` — the releases that carry the NamedTuple arity fix above (hearth #313/#314,
landed in 0.4.1). Transitively: apache-avro `1.12.1` → `1.12.2` (the explicit pin moves with it
rather than silently downgrading the transitive), jackson-core/-databind `2.21.5` → `2.22.1`,
jackson-annotations `2.21` → `2.22`, slf4j-api `2.0.17` → `2.0.18` (commons-lang3 stays at
`3.18.0` — see the override removal below; commons-compress stays at `1.28.0`). No
derived Avro schema text changed: record names, field names and field order are byte-identical
across the bump (the 216-example avro suite, including every naming and union spec, passes
unmodified).
- **Downstream consumers of `cats-eo-avro` move off a vulnerable jackson.** Resolving what the
published POM actually declares, a consumer previously landed on **jackson-databind 2.20.0 — 5
OSV advisories, 2 of them HIGH** (PolymorphicTypeValidator bypass via generic type parameters,
array-subtype allowlist bypass, InetSocketAddress SSRF, per-property `@JsonIgnoreProperties`
bypass, `@JsonIgnore` bypass on records). With avro 1.12.2 they now land on **2.22.1, which has
none**. This was never covered by eo's `dependencyOverrides`: sbt's overrides are *build-local*
and emit no `<dependencyManagement>`, and eo's published POMs carry none, so downstream always
resolved jackson through avro's own parent BOM. eo's CI meanwhile compiled against the
overridden version and `dependency-submission` reported it, which is why the exposure never
raised an alert here.
- **The jackson / commons-lang3 `dependencyOverrides` are removed.** They protected no consumer
(above) and had become a hazard: only two thirds of the BOM-managed jackson trio was ever
overridden (`jackson-annotations` was not), so the next `jackson-bom` lift inside avro-parent
would have moved annotations alone while core/databind stayed frozen. Measured, the jackson half
is now a pure no-op — the avro compile classpath is jar-for-jar identical with and without it.
Dropping the commons-lang3 override moves this build 3.20.0 → **3.18.0**, which is exactly what
a consumer resolves (commons-lang3 arrives via `commons-compress 1.28.0`, whose POM declares
3.18.0; avro-parent's 3.20.0 property manages only avro's own direct dependencies). 3.18.0 is
the first release patched against CVE-2025-48924 and carries zero OSV advisories. The build now
resolves avro's transitives exactly as consumers do, so `dependency-submission` reports the real
consumer graph instead of a locally-sweetened one.
- **The kindlings macro-expansion timeout actually takes effect now.** The build spelled it
`-Xmacro-settings:{circe,cats,avro}Derivation.timeout=30`, but kindlings parses that value with
`^\s*(\d+)\s*(ms|millis|milliseconds|s|seconds?|m|minutes?)\s*$` and falls back to its 5 s
default on no match — so a bare integer was silently discarded and every derivation had been
running on 5 s, not 30 s. Verified by A/B: `=30` behaves byte-for-byte like passing no setting
at all, while `=30s` raises the budget. All spellings gain the unit suffix (`=30s` build-wide,
`=120s` for mdoc fences), which is what finally mitigates the recurring "derivation timed out"
CI flake the setting was added for.
- **Behaviour change, avro**: a hand-written codec that PERMUTES the Scala names (writes case field
`a` into a schema field literally named `b`, and vice versa) resolved correctly by position and
now resolves by name, i.e. wrongly. No name transform can produce that shape — a transform is a
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ advises**. Operator runbook: `.github/bench/README.md`.

`generics/` is a separate sub-project that synthesises boilerplate
optics at compile time, built on top of Mateusz Kubuszok's
[`com.kubuszok:hearth_3:0.3.0`](https://github.com/MateuszKubuszok/hearth)
[`com.kubuszok:hearth_3:0.4.2`](https://github.com/MateuszKubuszok/hearth)
macro-commons library. Two entry points so far:

```scala
Expand Down
4 changes: 2 additions & 2 deletions avro/src/main/scala/dev/constructive/eo/avro/AvroCodec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import org.apache.avro.io.DecoderFactory
* `get` decodes, its `reverseGet` / `place` encodes). Forcing every call site to thread two
* `using` parameters is noisy. `AvroCodec[A]` is the project-internal shorthand.
*
* Why not vulcan? Vulcan 1.13.x pins apache-avro 1.11.5; kindlings-avro-derivation 0.1.2 pins
* 1.12.1. cats-eo-avro chose kindlings + avro 1.12 because it lines up with the rest of the
* Why not vulcan? Vulcan 1.13.x pins apache-avro 1.11.5; kindlings-avro-derivation 0.3.2 pins
* 1.12.2. cats-eo-avro chose kindlings + avro 1.12 because it lines up with the rest of the
* ecosystem moving forward, and the typeclass surface is simpler (no `Either[AvroError, A]`
* threading on every call — kindlings' decoders throw on failure, which the prism layer wraps into
* [[AvroFailure]]).
Expand Down
21 changes: 16 additions & 5 deletions avro/src/main/scala/dev/constructive/eo/avro/AvroPrism.scala
Original file line number Diff line number Diff line change
Expand Up @@ -413,11 +413,22 @@ object AvroPrism:
/** `.fields(_.a, _.b, ...)` — focus a NamedTuple over selected fields.
*
* The `AvroCodec` for the synthesised NamedTuple is summoned at the call site; with no
* hand-written given in scope it auto-derives through kindlings. That works for '''2 to 22'''
* selectors. At 23 or more, Scala has no `TupleN` spelling left — the focus type IS a `*:` cons
* chain — and kindlings 0.3.0 / hearth 0.4.0 cannot construct one, so the call fails to compile
* with `too many arguments for constructor *:`. Chain two `.fields` covers, or drill with
* `.field`, until the dependency moves to hearth ≥ 0.4.2. (Issue #96.)
* hand-written given in scope it auto-derives through kindlings. The 22-selector ceiling is
* gone: up to 22 selectors the focus is spelled `TupleN` and hearth builds it with that tuple's
* constructor; at 23 and above the focus type IS a `*:` cons chain, which hearth ≥ 0.4.2 builds
* through `Tuple.fromArray` instead. (Both spellings are needed — hearth's constructor branch
* below 23 cannot build a cons chain, and there is no `TupleN` above 22.)
*
* That is not the same as "unbounded". `.fields` selects from a case class, so '''254 selectors
* is a hard, permanent ceiling''' — the JVM caps a parameter list at 254 slots and a 255-field
* case class does not compile at all. Well below that, the binding limit is the compiler
* thread's stack, because the derivation recurses per field: measured, `-Xss1m` (the JVM
* default) tops out around 32 selectors, `-Xss4m` around 150, and `-Xss8m` — what this repo's
* `.jvmopts` sets — reaches 254. A downstream build gets none of that automatically, so raise
* `-Xss` there before concluding a cover is too wide. Compile time is the third cost: the
* derivation grows superlinearly in arity, so a wide cover may also want a higher
* `-Xmacro-settings:avroDerivation.timeout=30s` — the unit suffix is required, a bare integer is
* silently ignored. (Issue #96.)
*/
extension [A](o: AvroPrism[A])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ import org.specs2.mutable.Specification
* - `String *: Int *: EmptyTuple` (a `*:` cons chain), and
* - `(String, Int)` (i.e. `scala.Tuple2[String, Int]`)
*
* — but only the second is constructible by hearth's `SyntheticNamedTupleConstructor`, which for
* arity &lt; 23 emits `new <Values-primary-constructor>(args…)`. For the cons spelling that is
* — but only the second is constructible by hearth's `SyntheticNamedTupleConstructor` below arity
* 23, where it emits `new <Values-primary-constructor>(args…)`. For the cons spelling that is
* `new *:(a, b)`, and `*:` declares no value parameters, so any third-party derivation that
* '''builds''' the NamedTuple (kindlings' `AvroDecoderHandleAsNamedTupleRule`) blows up with
* `wrong number of arguments at inlining … expected: 0, found: N` plus a secondary
* `a reference to method decode_…$macro$N was used outside the scope where it was defined`.
* `a reference to method decode_…$macro$N was used outside the scope where it was defined`. That
* `n < 23` branch is unchanged in hearth 0.4.2 — only arity '''>= 23''' gained a `Tuple.fromArray`
* path — so the TupleN spelling stays mandatory at 2..22, permanently.
*
* Every other in-repo `.fields` spec hand-declares its own `AvroCodec[NamedTuple[…]]` given
* (spelled `TupleN`, because that is what a human writes), which short-circuits kindlings' NT rule
Expand Down Expand Up @@ -136,31 +138,72 @@ class NamedTupleSpellingSpec extends Specification:
org.specs2.execute.Failure(s"complement round-trip failed: $t"): org.specs2.execute.Result
}

// ---- the known arity ceiling (pins CURRENT behaviour) -----------------
// ---- above the old arity ceiling (was a pinned failure) ---------------

// covers: at 23+ selectors there IS no `TupleN` spelling — `(A, …, A)` with 23 components IS
// the `*:` cons chain — so the fix above cannot reach that far, and hearth 0.4.0 (shipped by
// kindlings-avro-derivation 0.3.0, this project's pin) still fails to construct it.
//
// This row PINS the ceiling rather than leaving it a silent gap. hearth 0.4.2 / kindlings
// 0.3.2 route every arity through `Tuple.fromArray` and would make it compile; when this
// project bumps kindlings, THIS TEST WILL FAIL — that failure is the signal to delete the
// row and the ceiling note in the `.fields` scaladoc, not a regression.
"`.fields` at arity 23: still unsupported on kindlings 0.3.0 / hearth 0.4.0 (documented ceiling)" >> {
val wide = typeCheckErrors("""
import dev.constructive.eo.avro.codecPrism
import dev.constructive.eo.avro.NamedTupleSpellingSpec.Wide23
codecPrism[Wide23].fields(
_.f01, _.f02, _.f03, _.f04, _.f05, _.f06, _.f07, _.f08,
_.f09, _.f10, _.f11, _.f12, _.f13, _.f14, _.f15, _.f16,
_.f17, _.f18, _.f19, _.f20, _.f21, _.f22, _.f23,
)
// the `*:` cons chain — so the TupleN spelling above cannot reach that far. This used to be
// a pinned FAILURE (hearth 0.4.0 emitted `new *:(args…)` and `*:` takes no value params).
// hearth 0.4.2's `SyntheticNamedTupleConstructor` routes arity >= 23 through
// `Tuple.fromArray` instead, so the row is now the positive assertion its own note asked for.
// Still given-free, like every other row here — this is the auto-derivation path.
// The wide end of the surface (mixed field types, un-selected siblings, reference identity)
// is covered separately by `WideFieldsArityCeilingSpec`.
"`.fields` at arity 23: compiles and round-trips on hearth 0.4.2 (ceiling broken)" >> {
val record = wide23Record
val L = codecPrism[Wide23]
.fields(
_.f01,
_.f02,
_.f03,
_.f04,
_.f05,
_.f06,
_.f07,
_.f08,
_.f09,
_.f10,
_.f11,
_.f12,
_.f13,
_.f14,
_.f15,
_.f16,
_.f17,
_.f18,
_.f19,
_.f20,
_.f21,
_.f22,
_.f23,
)
.record

L.getOptionUnsafe(record) match
case Some(nt) =>
// `Tuple.fromArray` builds a TupleXXL, NOT a TupleN — the branch that fixed this.
(nt.asInstanceOf[AnyRef].getClass.getName === "scala.runtime.TupleXXL")
.and(nt.f01 === "a01")
.and(nt.f23 === "a23")
case None =>
org.specs2.execute.Failure("expected Some(namedTuple)"): org.specs2.execute.Result
}

// covers: hearth 0.4.2's fix is scoped to arity >= 23 ONLY — its `n < 23` branch still emits
// `new <Values-primary-constructor>(args…)`, which for a cons chain is the value-parameterless
// `new *:(a, b)`. So `MacroSelectors.tupleTypeOf`'s "TupleN at <= 22, cons fold above" split is
// a PERMANENT requirement of the hearth contract, not a workaround the bump makes redundant:
// collapsing it to a uniform cons fold would re-break every `.fields` call of arity 2..22.
// This row is the executable proof, so a future simplification pass fails loudly.
"a cons-spelled NamedTuple below arity 23 is still unconstructible (the TupleN branch is permanent)" >> {
val errs = typeCheckErrors("""
import hearth.kindlings.avroderivation.AvroDecoder
type ConsNT =
NamedTuple.NamedTuple["a" *: "b" *: EmptyTuple, String *: String *: EmptyTuple]
val d: AvroDecoder[ConsNT] = AvroDecoder.derived
""")

(wide.nonEmpty === true)
.and(
wide.exists(_.message.contains("too many arguments for constructor *:")) === true
)
(errs.nonEmpty === true)
.and(errs.exists(_.message.contains("*:")) === true)
}

// ---- helpers ----------------------------------------------------------
Expand Down Expand Up @@ -193,7 +236,9 @@ object NamedTupleSpellingSpec:
def skuRecord(s: Sku): GenericRecord =
summon[AvroCodec[Sku]].encode(s).asInstanceOf[GenericRecord]

/** 23 fields — one past the last arity that has a `TupleN` spelling. */
/** 23 fields — one past the last arity that has a `TupleN` spelling, so `.fields` over all of
* them is spelled as a `*:` cons chain and built by hearth's `Tuple.fromArray` branch.
*/
case class Wide23(
f01: String,
f02: String,
Expand Down Expand Up @@ -226,6 +271,37 @@ object NamedTupleSpellingSpec:
given AvroDecoder[Wide23] = AvroDecoder.derived
given AvroSchemaFor[Wide23] = AvroSchemaFor.derived

lazy val wide23Record: GenericRecord =
summon[AvroCodec[Wide23]]
.encode(
Wide23(
"a01",
"a02",
"a03",
"a04",
"a05",
"a06",
"a07",
"a08",
"a09",
"a10",
"a11",
"a12",
"a13",
"a14",
"a15",
"a16",
"a17",
"a18",
"a19",
"a20",
"a21",
"a22",
"a23",
)
)
.asInstanceOf[GenericRecord]

// Deliberately NO `given AvroCodec[NamedTuple[…]]` anywhere in this file — see the class
// scaladoc. Adding one would silently restore the pre-fix pass.

Expand Down
Loading