From 84bbaf987020e86076da7d7bd35adf964ffcc38e Mon Sep 17 00:00:00 2001 From: mariano Date: Wed, 8 Jul 2026 23:11:00 -0500 Subject: [PATCH 1/4] build!: raise the compile baseline to Java 21 and restore the reverted language features Reverses the Java 17 cross-compile downgrade (#80) now that the supported floor moves to the current LTS. The toolchain stays at 25; only --release rises. - options.release 17 -> 21 in all 14 module build files; @SupportedSourceVersion RELEASE_17 -> RELEASE_21 on the five annotation processors; README JVM badge 17+ -> 21+. - Pattern-match switch expressions restored over the sealed hierarchies in Either, Validated, EitherK, ValidatedK, DeepMap, and ValidatedOrderController; the "unreachable" fallthrough throws are gone (exhaustiveness is compiler-checked). Record patterns restored in Validated.combine, ValidatedK.map2, and DeepMap. - Sequenced Collections: getFirst() restored across the codegen processors, example demos, and tests; positional get(0)/get(1) pairs (Map key/value type arguments) intentionally stay indexed. - try-with-resources on ExecutorService restored in BoundedAsyncTest and the Telescope javadoc example. - BridgeProcessor emits a pattern-match switch for sealed-bridge dispatch again; BridgeProcessorTest assertions updated. Generated navigators and bridges now require consumers to compile at 21+. - Telescope.BridgeTelescope returns to private (the Java 17 sealed-permits accessibility workaround is no longer needed). BREAKING CHANGE: consumers on Java 17-20 can no longer run telescope; the runtime floor is Java 21, and code generated by telescope-codegen requires -source 21+ downstream. Unnamed variables (_, JEP 456) stay out of scope - they need Java 22+ and the floor is deliberately the LTS. Closes #217 --- README.md | 2 +- benchmarks/build.gradle.kts | 2 +- codegen/build.gradle.kts | 2 +- .../codegen/AbstractTelescopeProcessor.java | 2 +- .../telescope/codegen/BeanFocusProcessor.java | 2 +- .../telescope/codegen/BridgeProcessor.java | 24 ++--- .../telescope/codegen/FocusProcessor.java | 4 +- .../telescope/codegen/FromMapProcessor.java | 4 +- .../codegen/MapperVerifierProcessor.java | 2 +- core/build.gradle.kts | 2 +- .../github/eschizoid/telescope/DeepMap.java | 101 +++++++++--------- .../github/eschizoid/telescope/Telescope.java | 7 +- .../eschizoid/telescope/effects/Either.java | 13 ++- .../telescope/effects/Validated.java | 25 ++--- .../telescope/runtime/instances/EitherK.java | 12 ++- .../runtime/instances/ValidatedK.java | 18 ++-- .../eschizoid/telescope/BoundedAsyncTest.java | 10 +- .../introspection/MapperLoggingTest.java | 2 +- examples/library/build.gradle.kts | 2 +- .../telescope/examples/DeepMappingDemo.java | 4 +- .../telescope/examples/MultiEditDemo.java | 7 +- .../mapstruct-vs-telescope/build.gradle.kts | 2 +- .../mapstruct/MapStructVsTelescopeTest.java | 4 +- .../springboot/invoicing/build.gradle.kts | 2 +- .../demo/invoicing/InvoiceBridgeFlowTest.java | 6 +- .../springboot/order-jpa/build.gradle.kts | 2 +- .../spring/api/ValidatedOrderController.java | 13 +-- .../springboot/org-chart/build.gradle.kts | 2 +- .../product-starter/build.gradle.kts | 2 +- internal/build.gradle.kts | 2 +- lombok/build.gradle.kts | 2 +- .../codegen/lombok/LombokFocusProcessor.java | 2 +- quarkus/build.gradle.kts | 2 +- spring-boot-starter/build.gradle.kts | 2 +- 34 files changed, 141 insertions(+), 149 deletions(-) diff --git a/README.md b/README.md index ecab4721..381bfac3 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ keeps going where MapStruct structurally stops — deep navigation, effectful up merge, JPA-cycle and Hibernate-`LAZY` handling, all from one `Telescope` type. Same speed, compile safety MapStruct can't give you, and a strictly larger surface. [See it row by row →](#how-it-compares-to-mapstruct) -[![JVM 17+](https://img.shields.io/badge/JVM-17%2B-brightgreen.svg?&logo=openjdk)](https://openjdk.org/projects/jdk/17/) +[![JVM 21+](https://img.shields.io/badge/JVM-21%2B-brightgreen.svg?&logo=openjdk)](https://openjdk.org/projects/jdk/21/) [![Build](https://github.com/eschizoid/telescope/actions/workflows/ci.yaml/badge.svg)](https://github.com/eschizoid/telescope/actions/workflows/ci.yaml) [![Codecov](https://codecov.io/gh/eschizoid/telescope/graph/badge.svg?token=a235ea8b-e6dc-45c6-8fea-e5050940c5d4)](https://codecov.io/gh/eschizoid/telescope) [![Maven Central](https://img.shields.io/maven-central/v/io.github.eschizoid/telescope-core.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/io.github.eschizoid/telescope-core) diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 92bae746..b3e53169 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -23,7 +23,7 @@ dependencies { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" modularity.inferModulePath = true } diff --git a/codegen/build.gradle.kts b/codegen/build.gradle.kts index 06c95eba..849cae8e 100644 --- a/codegen/build.gradle.kts +++ b/codegen/build.gradle.kts @@ -25,7 +25,7 @@ java { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" options.compilerArgs.addAll(listOf("-Xlint:all,-processing", "-parameters")) } diff --git a/codegen/src/main/java/io/github/eschizoid/telescope/codegen/AbstractTelescopeProcessor.java b/codegen/src/main/java/io/github/eschizoid/telescope/codegen/AbstractTelescopeProcessor.java index eefc03e0..bbb42199 100644 --- a/codegen/src/main/java/io/github/eschizoid/telescope/codegen/AbstractTelescopeProcessor.java +++ b/codegen/src/main/java/io/github/eschizoid/telescope/codegen/AbstractTelescopeProcessor.java @@ -924,7 +924,7 @@ private void emitBeanConstantsMap(final PrintWriter out, final List props) if (props.isEmpty()) { out.println(" return Map.of();"); } else if (props.size() == 1) { - final var onlyName = props.get(0).name(); + final var onlyName = props.getFirst().name(); out.println(" return Map.of(\"" + onlyName + "\", " + onlyName + ");"); } else { out.println(" return Map.ofEntries("); diff --git a/codegen/src/main/java/io/github/eschizoid/telescope/codegen/BeanFocusProcessor.java b/codegen/src/main/java/io/github/eschizoid/telescope/codegen/BeanFocusProcessor.java index aa67cf5a..230eb9e1 100644 --- a/codegen/src/main/java/io/github/eschizoid/telescope/codegen/BeanFocusProcessor.java +++ b/codegen/src/main/java/io/github/eschizoid/telescope/codegen/BeanFocusProcessor.java @@ -20,7 +20,7 @@ * setters — see {@link AbstractTelescopeProcessor#emitBeanNavigator}. */ @SupportedAnnotationTypes("io.github.eschizoid.telescope.annotations.BeanFocus") -@SupportedSourceVersion(SourceVersion.RELEASE_17) +@SupportedSourceVersion(SourceVersion.RELEASE_21) public final class BeanFocusProcessor extends AbstractTelescopeProcessor { /** diff --git a/codegen/src/main/java/io/github/eschizoid/telescope/codegen/BridgeProcessor.java b/codegen/src/main/java/io/github/eschizoid/telescope/codegen/BridgeProcessor.java index 10dd729c..e98ed2d7 100644 --- a/codegen/src/main/java/io/github/eschizoid/telescope/codegen/BridgeProcessor.java +++ b/codegen/src/main/java/io/github/eschizoid/telescope/codegen/BridgeProcessor.java @@ -64,7 +64,7 @@ @SupportedAnnotationTypes( { "io.github.eschizoid.telescope.annotations.Bridge", "io.github.eschizoid.telescope.annotations.Bridges" } ) -@SupportedSourceVersion(SourceVersion.RELEASE_17) +@SupportedSourceVersion(SourceVersion.RELEASE_21) public final class BridgeProcessor extends AbstractTelescopeProcessor { /** @@ -1850,7 +1850,7 @@ record CaseEntry(TypeElement sourceCase, TypeElement targetCase, String bridgeFq ); } else if (caseBridges.size() == 1) { // Single @Bridge whose target isn't in the sealed-target permits — name it explicitly. - final var only = rawTargetValueFromMirror(caseBridges.get(0)); + final var only = rawTargetValueFromMirror(caseBridges.getFirst()); if (!(only instanceof DeclaredType decl)) continue; final var onlyEl = (TypeElement) decl.asElement(); error( @@ -2170,13 +2170,13 @@ private ContainerShape containerShapeOf(final TypeMirror type) { if (!(type instanceof DeclaredType dt)) return null; final var args = dt.getTypeArguments(); if (assignableToRaw(type, "java.util.Optional")) { - return args.size() == 1 ? new ContainerShape(FieldPlan.Kind.OPTIONAL, args.get(0), null) : null; + return args.size() == 1 ? new ContainerShape(FieldPlan.Kind.OPTIONAL, args.getFirst(), null) : null; } if (args.size() == 1 && assignableToRaw(type, "java.util.List")) { - return new ContainerShape(FieldPlan.Kind.LIST, args.get(0), null); + return new ContainerShape(FieldPlan.Kind.LIST, args.getFirst(), null); } if (args.size() == 1 && assignableToRaw(type, "java.util.Set")) { - return new ContainerShape(FieldPlan.Kind.SET, args.get(0), null); + return new ContainerShape(FieldPlan.Kind.SET, args.getFirst(), null); } if (args.size() == 2 && assignableToRaw(type, "java.util.Map")) { return new ContainerShape(FieldPlan.Kind.MAP_VALUES, args.get(1), args.get(0)); @@ -2194,11 +2194,11 @@ private ContainerShape rawContainerShapeOf(final TypeMirror type) { if (!(type instanceof DeclaredType dt) || !dt.getTypeArguments().isEmpty()) return null; if (assignableToRaw(type, "java.util.List")) { final var args = containerViewArgs(type, "java.util.List"); - return args.size() == 1 ? new ContainerShape(FieldPlan.Kind.LIST, args.get(0), null) : null; + return args.size() == 1 ? new ContainerShape(FieldPlan.Kind.LIST, args.getFirst(), null) : null; } if (assignableToRaw(type, "java.util.Set")) { final var args = containerViewArgs(type, "java.util.Set"); - return args.size() == 1 ? new ContainerShape(FieldPlan.Kind.SET, args.get(0), null) : null; + return args.size() == 1 ? new ContainerShape(FieldPlan.Kind.SET, args.getFirst(), null) : null; } if (assignableToRaw(type, "java.util.Map")) { final var args = containerViewArgs(type, "java.util.Map"); @@ -2930,7 +2930,7 @@ private String rawAllocExpr(final TypeMirror container, final FieldPlan.Kind kin return "new " + implFqn + "<" + args.get(0) + ", " + args.get(1) + ">()"; } final var args = containerViewArgs(container, kind == FieldPlan.Kind.SET ? "java.util.Set" : "java.util.List"); - return "new " + implFqn + "<" + args.get(0) + ">()"; + return "new " + implFqn + "<" + args.getFirst() + ">()"; } private void emitListHelper( @@ -2941,8 +2941,8 @@ private void emitListHelper( final String subBridge, final String direction ) { - final var srcElement = ((DeclaredType) srcContainer).getTypeArguments().get(0); - final var tgtElement = ((DeclaredType) tgtContainer).getTypeArguments().get(0); + final var srcElement = ((DeclaredType) srcContainer).getTypeArguments().getFirst(); + final var tgtElement = ((DeclaredType) tgtContainer).getTypeArguments().getFirst(); final var returnRaw = simpleName(containerRawFqn(tgtContainer)); final var paramRaw = simpleName(containerRawFqn(srcContainer)); final var implFqn = concreteImplFqn(tgtContainer, FieldPlan.Kind.LIST); @@ -2977,8 +2977,8 @@ private void emitSetHelper( final String subBridge, final String direction ) { - final var srcElement = ((DeclaredType) srcContainer).getTypeArguments().get(0); - final var tgtElement = ((DeclaredType) tgtContainer).getTypeArguments().get(0); + final var srcElement = ((DeclaredType) srcContainer).getTypeArguments().getFirst(); + final var tgtElement = ((DeclaredType) tgtContainer).getTypeArguments().getFirst(); final var returnRaw = simpleName(containerRawFqn(tgtContainer)); final var paramRaw = simpleName(containerRawFqn(srcContainer)); final var implFqn = concreteImplFqn(tgtContainer, FieldPlan.Kind.SET); diff --git a/codegen/src/main/java/io/github/eschizoid/telescope/codegen/FocusProcessor.java b/codegen/src/main/java/io/github/eschizoid/telescope/codegen/FocusProcessor.java index 657fddb8..e8396005 100644 --- a/codegen/src/main/java/io/github/eschizoid/telescope/codegen/FocusProcessor.java +++ b/codegen/src/main/java/io/github/eschizoid/telescope/codegen/FocusProcessor.java @@ -44,7 +44,7 @@ * */ @SupportedAnnotationTypes("io.github.eschizoid.telescope.annotations.Focus") -@SupportedSourceVersion(SourceVersion.RELEASE_17) +@SupportedSourceVersion(SourceVersion.RELEASE_21) public final class FocusProcessor extends AbstractTelescopeProcessor { /** @@ -231,7 +231,7 @@ private void emitRecordConstantsMap(final PrintWriter out, final List hintTargets ) { if (!"writeBean".contentEquals(rowExec.getSimpleName())) return; - final var target = row.getArguments().isEmpty() ? null : classLiteral(row.getArguments().get(0)); + final var target = row.getArguments().isEmpty() ? null : classLiteral(row.getArguments().getFirst()); final var targetEl = target == null ? null : props.elementOf(target); if (targetEl == null) { note( diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 33721940..dce1282b 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -24,7 +24,7 @@ java { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" options.compilerArgs.addAll(listOf("-Xlint:all,-processing", "-Werror", "-parameters")) } diff --git a/core/src/main/java/io/github/eschizoid/telescope/DeepMap.java b/core/src/main/java/io/github/eschizoid/telescope/DeepMap.java index 80c2f4ee..ead51189 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/DeepMap.java +++ b/core/src/main/java/io/github/eschizoid/telescope/DeepMap.java @@ -262,11 +262,11 @@ private static NullHint.NullStrategy extractNullStrategy(final List hi private static WriteHint.WriteStrategy extractDefaultStrategy(final List> hints) { WriteHint.WriteStrategy defaultStrategy = null; for (final var hint : hints) { - if (!(hint instanceof WriteHint.DefaultWriteHint defaultHint)) continue; + if (!(hint instanceof WriteHint.DefaultWriteHint(final WriteHint.WriteStrategy strat))) continue; if (defaultStrategy != null) throw new IllegalArgumentException( "Duplicate writeBeans(...) default — at most one default write strategy per Telescope.map(...) call." ); - defaultStrategy = defaultHint.strategy(); + defaultStrategy = strat; } return defaultStrategy; } @@ -1161,53 +1161,56 @@ private static T overrideTargetField( // Inline the contributed leaf-level Iso for each row variant. Reading the public components // directly keeps Iso (internal) out of the mapping types' public signatures — so the mapping // types stay portable across packages without needing @SuppressWarnings("exports"). - // SameTypedTo carries no conversion; populateIso resolves it through autoIso (identity / - // primitive-wrapper / container lifting / incompatible-shape rejection), so it must not reach - // here. The guard surfaces a routing regression with a clear class name, like the rows below. - if (row instanceof SameTypedTo) throw new IllegalStateException( - "SameTypedTo row should be resolved via autoIso, not fieldIsoOf" - ); - if (row instanceof TypedTransformTo r) return Iso.of((Function) r.forward(), (Function) r.backward()); - if (row instanceof ForwardOnlyTransformTo r) { - // DEAD-BRANCH-DEFENSIVE: this throwingBackward lambda is unreachable via the public API. - // Both factory entries Telescope.map(...) and Telescope.mapper(...) call - // rejectForwardOnlyRows - // up front; Telescope.mapperForward(...) accepts the row but never invokes the backward leg. - // The guard remains so a future cross-package construction path that bypasses - // rejectForwardOnlyRows produces a precise field-naming error rather than silent corruption. - // NOT a coverage target. - final String fieldName = r.sourceField(); - final Function throwingBackward = y -> { - throw new UnsupportedOperationException( - "Mapping.toOneWay is forward-only — backward direction is undefined for field '" + - fieldName + - "'. Use Telescope.mapperForward(...) for a forward-only mapper, or Mapping.to(src, " + - "tgt, forward, backward) for an explicit bidirectional row." - ); - }; - return Iso.of((Function) r.forward(), throwingBackward); - } - if (row instanceof Via r) return liftViaIfNeeded(r, srcType, tgtType); - // DEAD-BRANCH-DEFENSIVE block (rows below): every permit of the sealed Mapping hierarchy that - // is NOT a per-field leaf Iso is filtered out by populateIso BEFORE this method is called. The - // checks remain solely as a compile-time exhaustiveness backstop — if a future permit is added - // to Mapping and populateIso forgets to short-circuit, the corresponding throw here surfaces - // the routing bug with a clear class name. NOT coverage targets — these can only fire when a - // routing change in populateIso introduces a regression, at which point the test failure is in - // populateIso, not here. - if (row instanceof Drop) throw new IllegalStateException("Drop row should not reach fieldIsoOf"); - if (row instanceof TelescopeTo) throw new IllegalStateException( - "TelescopeTo row should not reach fieldIsoOf" - ); - if (row instanceof FromTelescopeTo) throw new IllegalStateException( - "FromTelescopeTo row should not reach fieldIsoOf" - ); - if (row instanceof TelescopeToTelescope) throw new IllegalStateException( - "TelescopeToTelescope row should not reach fieldIsoOf" - ); - if (row instanceof Constant) throw new IllegalStateException("Constant row should not reach fieldIsoOf"); - if (row instanceof Compute) throw new IllegalStateException("Compute row should not reach fieldIsoOf"); - throw new IllegalStateException("unreachable: Mapping is sealed"); + return switch (row) { + // SameTypedTo carries no conversion; populateIso resolves it through autoIso (identity / + // primitive-wrapper / container lifting / incompatible-shape rejection), so it must not reach + // here. The guard surfaces a routing regression with a clear class name, like the cases + // below. + case SameTypedTo __ -> throw new IllegalStateException( + "SameTypedTo row should be resolved via autoIso, not fieldIsoOf" + ); + case TypedTransformTo r -> Iso.of((Function) r.forward(), (Function) r.backward()); + case ForwardOnlyTransformTo r -> { + // DEAD-BRANCH-DEFENSIVE: this throwingBackward lambda is unreachable via the public API. + // Both factory entries Telescope.map(...) and Telescope.mapper(...) call + // rejectForwardOnlyRows + // up front; Telescope.mapperForward(...) accepts the row but never invokes the backward + // leg. The guard remains so a future cross-package construction path that bypasses + // rejectForwardOnlyRows produces a precise field-naming error rather than silent + // corruption. NOT a coverage target. + final String fieldName = r.sourceField(); + final Function throwingBackward = y -> { + throw new UnsupportedOperationException( + "Mapping.toOneWay is forward-only — backward direction is undefined for field '" + + fieldName + + "'. Use Telescope.mapperForward(...) for a forward-only mapper, or Mapping.to(src, " + + "tgt, forward, backward) for an explicit bidirectional row." + ); + }; + yield Iso.of((Function) r.forward(), throwingBackward); + } + case Via r -> liftViaIfNeeded(r, srcType, tgtType); + // DEAD-BRANCH-DEFENSIVE block (cases below): every permit of the sealed Mapping hierarchy + // that is NOT a per-field leaf Iso is filtered out by populateIso BEFORE this method is + // called. The cases exist only because the sealed switch must stay exhaustive — if a future + // permit is added to Mapping and populateIso forgets to short-circuit, the compiler forces a + // case here whose throw surfaces the routing bug with a clear class name. NOT coverage + // targets — these can only fire when a routing change in populateIso introduces a + // regression, at which point the test failure is in populateIso, not here. + case Drop __ -> throw new IllegalStateException("Drop row should not reach fieldIsoOf"); + case TelescopeTo __ -> throw new IllegalStateException("TelescopeTo row should not reach fieldIsoOf"); + case FromTelescopeTo __ -> throw new IllegalStateException( + "FromTelescopeTo row should not reach fieldIsoOf" + ); + case TelescopeToTelescope __ -> throw new IllegalStateException( + "TelescopeToTelescope row should not reach fieldIsoOf" + ); + case Constant __ -> throw new IllegalStateException("Constant row should not reach fieldIsoOf"); + case Compute __ -> throw new IllegalStateException("Compute row should not reach fieldIsoOf"); + case Conditional __ -> throw new IllegalStateException( + "Conditional row should be unwrapped by populateIso before fieldIsoOf" + ); + }; } /** diff --git a/core/src/main/java/io/github/eschizoid/telescope/Telescope.java b/core/src/main/java/io/github/eschizoid/telescope/Telescope.java index 9d85fc93..ec9fcf86 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/Telescope.java +++ b/core/src/main/java/io/github/eschizoid/telescope/Telescope.java @@ -1980,12 +1980,9 @@ public Optional updateOptional(final S source, final Function{@code - * final var pool = Executors.newFixedThreadPool(10); - * try { + * try (final var pool = Executors.newFixedThreadPool(10)) { * final CompletableFuture done = path.updateAsync(batch, this::fetch, pool); * done.join(); - * } finally { - * pool.shutdown(); * } * } */ @@ -2307,7 +2304,7 @@ public Telescope present() { // fn.backward in one virtual hop. The Iso is still the underlying optic — composition (.then, // .field, .each, .as, etc.) returns a regular Telescope and keeps lattice semantics intact; the // fast path applies only when a terminal is invoked directly on the bridge constant. - static final class BridgeTelescope extends Telescope { + private static final class BridgeTelescope extends Telescope { private final BridgeFn fn; diff --git a/core/src/main/java/io/github/eschizoid/telescope/effects/Either.java b/core/src/main/java/io/github/eschizoid/telescope/effects/Either.java index 126d25b4..1db7ad7d 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/effects/Either.java +++ b/core/src/main/java/io/github/eschizoid/telescope/effects/Either.java @@ -93,13 +93,12 @@ default boolean isLeft() { * } */ default T fold(final Function onLeft, final Function onRight) { - // Sealed-aware dispatch: a single instanceof + a guaranteed-safe cast on the other branch. - // Avoids the dead "second instanceof always true" branch the explicit double-instanceof pattern - // produces (which JaCoCo flags as a partial and a defensive throw line as unreachable). Every - // other default method on Either delegates here so the sealed-dispatch logic lives in one - // place. - if (this instanceof Left l) return onLeft.apply(l.value()); - return onRight.apply(((Right) this).value()); + // Every other default method on Either delegates here, so the sealed-dispatch logic lives in + // one place. Exhaustiveness over the sealed hierarchy is compiler-checked. + return switch (this) { + case Left l -> onLeft.apply(l.value()); + case Right r -> onRight.apply(r.value()); + }; } /** diff --git a/core/src/main/java/io/github/eschizoid/telescope/effects/Validated.java b/core/src/main/java/io/github/eschizoid/telescope/effects/Validated.java index c94ab237..f672daca 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/effects/Validated.java +++ b/core/src/main/java/io/github/eschizoid/telescope/effects/Validated.java @@ -119,12 +119,12 @@ default T fold( final Function, ? extends T> onInvalid, final Function onValid ) { - // Sealed-aware dispatch: a single instanceof + a guaranteed-safe cast on the other branch. // Every other default method on Validated delegates here, so the sealed-dispatch logic lives - // in one place and the partial-coverage / dead-throw branches the explicit double-instanceof - // pattern produces don't multiply across each method. - if (this instanceof Invalid inv) return onInvalid.apply(inv.errors()); - return onValid.apply(((Valid) this).value()); + // in one place. Exhaustiveness over the sealed hierarchy is compiler-checked. + return switch (this) { + case Invalid inv -> onInvalid.apply(inv.errors()); + case Valid v -> onValid.apply(v.value()); + }; } /** @@ -261,10 +261,9 @@ static Validated> combineAll(final List(inputs.size()); final var errors = new ArrayList(); for (final var v : inputs) { - if (v instanceof Valid ok) { - values.add(ok.value()); - } else if (v instanceof Invalid bad) { - errors.addAll(bad.errors()); + switch (v) { + case Valid ok -> values.add(ok.value()); + case Invalid bad -> errors.addAll(bad.errors()); } } if (!errors.isEmpty()) return new Invalid<>(errors); @@ -288,16 +287,14 @@ static Validated combine( final Validated right, final BiFunction f ) { - if (left instanceof Invalid leftInvalid && right instanceof Invalid rightInvalid) { - final var leftErrors = leftInvalid.errors(); - final var rightErrors = rightInvalid.errors(); + if (left instanceof Invalid(List leftErrors) && right instanceof Invalid(List rightErrors)) { final var combined = new ArrayList(leftErrors.size() + rightErrors.size()); combined.addAll(leftErrors); combined.addAll(rightErrors); return new Invalid<>(combined); } - if (left instanceof Invalid leftInvalid) return new Invalid<>(leftInvalid.errors()); - if (right instanceof Invalid rightInvalid) return new Invalid<>(rightInvalid.errors()); + if (left instanceof Invalid(List errors)) return new Invalid<>(errors); + if (right instanceof Invalid(List errors)) return new Invalid<>(errors); final var l = ((Valid) left).value(); final var r = ((Valid) right).value(); return new Valid<>(f.apply(l, r)); diff --git a/core/src/main/java/io/github/eschizoid/telescope/runtime/instances/EitherK.java b/core/src/main/java/io/github/eschizoid/telescope/runtime/instances/EitherK.java index 38b6c556..aa6ad672 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/runtime/instances/EitherK.java +++ b/core/src/main/java/io/github/eschizoid/telescope/runtime/instances/EitherK.java @@ -56,10 +56,14 @@ public Kind, C> map2( final Kind, B> fb, final BiFunction f ) { - // Sealed-aware nested dispatch through `fold` — short-circuits on the first Left and - // collapses the explicit double-instanceof chain (which left dead branches that JaCoCo - // flagged as partials / defensive throw lines). - return box(unbox(fa).flatMap(a -> unbox(fb).map(b -> f.apply(a, b)))); + // Short-circuits on the first Left: fb is only inspected once fa is known to be a Right. + return switch (unbox(fa)) { + case Either.Left la -> box(Either.left(la.value())); + case Either.Right ra -> switch (unbox(fb)) { + case Either.Left lb -> box(Either.left(lb.value())); + case Either.Right rb -> box(Either.right(f.apply(ra.value(), rb.value()))); + }; + }; } @Override diff --git a/core/src/main/java/io/github/eschizoid/telescope/runtime/instances/ValidatedK.java b/core/src/main/java/io/github/eschizoid/telescope/runtime/instances/ValidatedK.java index b874b997..7d3ee1df 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/runtime/instances/ValidatedK.java +++ b/core/src/main/java/io/github/eschizoid/telescope/runtime/instances/ValidatedK.java @@ -4,6 +4,7 @@ import io.github.eschizoid.telescope.internal.optics.Applicative; import io.github.eschizoid.telescope.internal.optics.Kind; import java.util.ArrayList; +import java.util.List; import java.util.function.BiFunction; import java.util.function.Function; @@ -63,16 +64,17 @@ public Kind, C> map2( ) { final var va = unbox(fa); final var vb = unbox(fb); - if (va instanceof Validated.Invalid invA && vb instanceof Validated.Invalid invB) { - final var errors1 = invA.errors(); - final var errors2 = invB.errors(); - final var combined = new ArrayList(errors1.size() + errors2.size()); - combined.addAll(errors1); - combined.addAll(errors2); + if ( + va instanceof Validated.Invalid(List errorsA) && + vb instanceof Validated.Invalid(List errorsB) + ) { + final var combined = new ArrayList(errorsA.size() + errorsB.size()); + combined.addAll(errorsA); + combined.addAll(errorsB); return box(Validated.invalid(combined)); } - if (va instanceof Validated.Invalid invA) return box(Validated.invalid(invA.errors())); - if (vb instanceof Validated.Invalid invB) return box(Validated.invalid(invB.errors())); + if (va instanceof Validated.Invalid(List errors)) return box(Validated.invalid(errors)); + if (vb instanceof Validated.Invalid(List errors)) return box(Validated.invalid(errors)); final var a = ((Validated.Valid) va).value(); final var b = ((Validated.Valid) vb).value(); return box(Validated.valid(f.apply(a, b))); diff --git a/core/src/test/java/io/github/eschizoid/telescope/BoundedAsyncTest.java b/core/src/test/java/io/github/eschizoid/telescope/BoundedAsyncTest.java index bcc6517e..18b51fbb 100644 --- a/core/src/test/java/io/github/eschizoid/telescope/BoundedAsyncTest.java +++ b/core/src/test/java/io/github/eschizoid/telescope/BoundedAsyncTest.java @@ -63,8 +63,7 @@ void boundsConcurrency() throws Exception { ) ); - final var pool = Executors.newFixedThreadPool(2); - try { + try (final var pool = Executors.newFixedThreadPool(2)) { final var done = USERS.updateAsync( team, user -> { @@ -85,8 +84,6 @@ void boundsConcurrency() throws Exception { assertEquals(6, result.users().size()); assertTrue(maxObserved.get() <= 2, "max concurrent invocations should be ≤ 2, was " + maxObserved.get()); assertTrue(maxObserved.get() >= 1, "must have at least 1 in-flight at peak"); - } finally { - pool.shutdown(); } } @@ -95,8 +92,7 @@ void boundsConcurrency() throws Exception { void rebuilds() throws Exception { final var team = new Team("eng", List.of(new User("alice", ""), new User("bob", ""))); - final var pool = Executors.newFixedThreadPool(2); - try { + try (final var pool = Executors.newFixedThreadPool(2)) { final var done = USERS.updateAsync( team, user -> CompletableFuture.completedFuture(new User(user.name(), "x")), @@ -104,8 +100,6 @@ void rebuilds() throws Exception { ); final var result = done.get(5, TimeUnit.SECONDS); assertEquals(List.of("x", "x"), result.users().stream().map(User::bio).toList()); - } finally { - pool.shutdown(); } } } diff --git a/core/src/test/java/io/github/eschizoid/telescope/introspection/MapperLoggingTest.java b/core/src/test/java/io/github/eschizoid/telescope/introspection/MapperLoggingTest.java index 96597d0d..0f204710 100644 --- a/core/src/test/java/io/github/eschizoid/telescope/introspection/MapperLoggingTest.java +++ b/core/src/test/java/io/github/eschizoid/telescope/introspection/MapperLoggingTest.java @@ -107,7 +107,7 @@ void debugLogsStructureNotValues() { }); // Exactly one record: the construction explain. No per-forward value trace leaks in at DEBUG. assertEquals(1, records.size(), () -> "expected only the construction explain at DEBUG; got " + messages(records)); - assertEquals(Level.FINE, records.get(0).getLevel(), "the construction log must be at DEBUG (JUL FINE)"); + assertEquals(Level.FINE, records.getFirst().getLevel(), "the construction log must be at DEBUG (JUL FINE)"); assertTrue(any(records, "Mapped:"), () -> "expected the explain structure; got " + messages(records)); assertFalse(any(records, "\"Ada\""), () -> "the value trace must not appear at DEBUG; got " + messages(records)); } diff --git a/examples/library/build.gradle.kts b/examples/library/build.gradle.kts index c911cf6c..beb24185 100644 --- a/examples/library/build.gradle.kts +++ b/examples/library/build.gradle.kts @@ -15,7 +15,7 @@ java { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" // -parameters lets WriteHint.CONSTRUCTOR match args by parameter name on the immutable POJO demo. options.compilerArgs.addAll(listOf("-Xlint:all,-processing", "-parameters")) diff --git a/examples/library/src/main/java/io/github/eschizoid/telescope/examples/DeepMappingDemo.java b/examples/library/src/main/java/io/github/eschizoid/telescope/examples/DeepMappingDemo.java index dc0e0247..c30bb8da 100644 --- a/examples/library/src/main/java/io/github/eschizoid/telescope/examples/DeepMappingDemo.java +++ b/examples/library/src/main/java/io/github/eschizoid/telescope/examples/DeepMappingDemo.java @@ -142,9 +142,9 @@ private static void deepMapWithRenames() { final CompanyDto dto = mapper.read(entity); System.out.println("[map] founded→since : " + dto.since()); System.out.println( - "[map] name→fullName (deep) : " + dto.departments().get(0).teams().get(0).users().get(0).fullName() + "[map] name→fullName (deep) : " + dto.departments().getFirst().teams().getFirst().users().getFirst().fullName() ); - System.out.println("[map] Optional head : " + dto.departments().get(0).head().orElseThrow().fullName()); + System.out.println("[map] Optional head : " + dto.departments().getFirst().head().orElseThrow().fullName()); System.out.println("[map] Map : " + dto.officesByRegion().get("EU").city()); } diff --git a/examples/library/src/main/java/io/github/eschizoid/telescope/examples/MultiEditDemo.java b/examples/library/src/main/java/io/github/eschizoid/telescope/examples/MultiEditDemo.java index ba6838f7..0c39a68e 100644 --- a/examples/library/src/main/java/io/github/eschizoid/telescope/examples/MultiEditDemo.java +++ b/examples/library/src/main/java/io/github/eschizoid/telescope/examples/MultiEditDemo.java @@ -72,10 +72,10 @@ static void run() { System.out.println("[all/over] dept names trimmed: " + out.departments().stream().map(Department::name).toList()); System.out.println( - "[all/over] team names trimmed: " + out.departments().get(0).teams().stream().map(Team::name).toList() + "[all/over] team names trimmed: " + out.departments().getFirst().teams().stream().map(Team::name).toList() ); System.out.println( - "[all/over] first user email : " + out.departments().get(0).teams().get(0).users().get(0).email() + "[all/over] first user email : " + out.departments().getFirst().teams().getFirst().users().getFirst().email() ); // The same normalizer is reusable on a different source. @@ -85,7 +85,8 @@ static void run() { ); final var another2 = normalize.apply(another); System.out.println( - "[all/over] reused on another : " + another2.departments().get(0).teams().get(0).users().get(0).email() + "[all/over] reused on another : " + + another2.departments().getFirst().teams().getFirst().users().getFirst().email() ); } } diff --git a/examples/mapstruct-vs-telescope/build.gradle.kts b/examples/mapstruct-vs-telescope/build.gradle.kts index d47ad50e..8dab9f72 100644 --- a/examples/mapstruct-vs-telescope/build.gradle.kts +++ b/examples/mapstruct-vs-telescope/build.gradle.kts @@ -16,7 +16,7 @@ java { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" // -processing keeps MapStruct's own processor notes out of -Xlint; -parameters lets MapStruct // and telescope match constructor args by name on the domain records. diff --git a/examples/mapstruct-vs-telescope/src/test/java/io/github/eschizoid/telescope/example/mapstruct/MapStructVsTelescopeTest.java b/examples/mapstruct-vs-telescope/src/test/java/io/github/eschizoid/telescope/example/mapstruct/MapStructVsTelescopeTest.java index 6bc7ca27..97666b10 100644 --- a/examples/mapstruct-vs-telescope/src/test/java/io/github/eschizoid/telescope/example/mapstruct/MapStructVsTelescopeTest.java +++ b/examples/mapstruct-vs-telescope/src/test/java/io/github/eschizoid/telescope/example/mapstruct/MapStructVsTelescopeTest.java @@ -104,12 +104,12 @@ void deepUpdateRebuildsImmutably() { final var taxed = TelescopeMappings.applyRate(order, new BigDecimal("2")); - assertEquals(new BigDecimal("20.00"), taxed.lines().get(0).price(), "every line price doubled"); + assertEquals(new BigDecimal("20.00"), taxed.lines().getFirst().price(), "every line price doubled"); assertEquals(new BigDecimal("10.00"), taxed.lines().get(1).price(), "every line price doubled"); assertNotSame(order, taxed, "a new Order graph is returned"); assertEquals( new BigDecimal("10.00"), - order.lines().get(0).price(), + order.lines().getFirst().price(), "the original Order is unchanged — immutable update" ); log( diff --git a/examples/springboot/invoicing/build.gradle.kts b/examples/springboot/invoicing/build.gradle.kts index 3f100155..efed6d91 100644 --- a/examples/springboot/invoicing/build.gradle.kts +++ b/examples/springboot/invoicing/build.gradle.kts @@ -38,7 +38,7 @@ dependencies { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" options.compilerArgs.addAll(listOf("-Xlint:all,-processing,-serial", "-parameters")) } diff --git a/examples/springboot/invoicing/src/test/java/io/github/eschizoid/telescope/demo/invoicing/InvoiceBridgeFlowTest.java b/examples/springboot/invoicing/src/test/java/io/github/eschizoid/telescope/demo/invoicing/InvoiceBridgeFlowTest.java index b5e9096b..6cb6dd7c 100644 --- a/examples/springboot/invoicing/src/test/java/io/github/eschizoid/telescope/demo/invoicing/InvoiceBridgeFlowTest.java +++ b/examples/springboot/invoicing/src/test/java/io/github/eschizoid/telescope/demo/invoicing/InvoiceBridgeFlowTest.java @@ -70,9 +70,9 @@ void postHeaderForwardRecursesIntoLineBridgeForEveryListElement() { assertThat(entity).isNotNull(); assertThat(entity.getNumber()).isEqualTo("INV-200"); assertThat(entity.getLines()).hasSize(2); - assertThat(entity.getLines().get(0).getSku()).isEqualTo("SKU-X"); - assertThat(entity.getLines().get(0).getQty()).isEqualTo(2); - assertThat(entity.getLines().get(0).getUnitPrice()).isEqualByComparingTo("11.50"); + assertThat(entity.getLines().getFirst().getSku()).isEqualTo("SKU-X"); + assertThat(entity.getLines().getFirst().getQty()).isEqualTo(2); + assertThat(entity.getLines().getFirst().getUnitPrice()).isEqualByComparingTo("11.50"); assertThat(entity.getLines().get(1).getSku()).isEqualTo("SKU-Y"); assertThat(entity.getLines().get(1).getQty()).isEqualTo(7); assertThat(entity.getLines().get(1).getUnitPrice()).isEqualByComparingTo("0.99"); diff --git a/examples/springboot/order-jpa/build.gradle.kts b/examples/springboot/order-jpa/build.gradle.kts index fafd24dc..a433e373 100644 --- a/examples/springboot/order-jpa/build.gradle.kts +++ b/examples/springboot/order-jpa/build.gradle.kts @@ -54,7 +54,7 @@ dependencies { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" // -parameters lets Spring's @PathVariable / @RequestParam resolve by name without // explicit annotation values, and helps Jackson's record-creator detection. diff --git a/examples/springboot/order-jpa/src/main/java/io/github/eschizoid/telescope/demo/spring/api/ValidatedOrderController.java b/examples/springboot/order-jpa/src/main/java/io/github/eschizoid/telescope/demo/spring/api/ValidatedOrderController.java index 21da6f34..12698356 100644 --- a/examples/springboot/order-jpa/src/main/java/io/github/eschizoid/telescope/demo/spring/api/ValidatedOrderController.java +++ b/examples/springboot/order-jpa/src/main/java/io/github/eschizoid/telescope/demo/spring/api/ValidatedOrderController.java @@ -34,15 +34,10 @@ public ResponseEntity create(@RequestBody final Order request) { : Validated.valid(quantity) ); - // Sealed-dispatch via fold — the canonical shape, single source of truth for Valid/Invalid - // routing. Returns the OK response on Valid; throws on Invalid (the throw needs an `unchecked - // cast` of the fold's T inference back to `ResponseEntity`). - return result.fold( - errors -> { - throw new InvalidOrderException(new Validated.Invalid<>(errors)); - }, - ResponseEntity::ok - ); + return switch (result) { + case Validated.Valid(final Order accepted) -> ResponseEntity.ok(accepted); + case Validated.Invalid bad -> throw new InvalidOrderException(bad); + }; } // Recover the SKU of the first line item with this quantity for error context. The validator diff --git a/examples/springboot/org-chart/build.gradle.kts b/examples/springboot/org-chart/build.gradle.kts index e64a7aa7..893f4443 100644 --- a/examples/springboot/org-chart/build.gradle.kts +++ b/examples/springboot/org-chart/build.gradle.kts @@ -43,7 +43,7 @@ dependencies { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" options.compilerArgs.addAll(listOf("-Xlint:all,-processing,-serial", "-parameters")) } diff --git a/examples/springboot/product-starter/build.gradle.kts b/examples/springboot/product-starter/build.gradle.kts index 626ef993..2d466229 100644 --- a/examples/springboot/product-starter/build.gradle.kts +++ b/examples/springboot/product-starter/build.gradle.kts @@ -49,7 +49,7 @@ dependencies { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" options.compilerArgs.addAll(listOf("-Xlint:all,-processing,-serial", "-parameters")) } diff --git a/internal/build.gradle.kts b/internal/build.gradle.kts index 043a2358..fae79ddc 100644 --- a/internal/build.gradle.kts +++ b/internal/build.gradle.kts @@ -24,7 +24,7 @@ java { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" // -Xlint:-module suppresses the "qualified-export target module not found" warning that fires // because :core (io.github.eschizoid.telescope) requires :internal, not the other way around, diff --git a/lombok/build.gradle.kts b/lombok/build.gradle.kts index 4c4651cb..0a4839d9 100644 --- a/lombok/build.gradle.kts +++ b/lombok/build.gradle.kts @@ -24,7 +24,7 @@ java { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" options.compilerArgs.addAll(listOf("-Xlint:all,-processing", "-parameters")) } diff --git a/lombok/src/main/java/io/github/eschizoid/telescope/codegen/lombok/LombokFocusProcessor.java b/lombok/src/main/java/io/github/eschizoid/telescope/codegen/lombok/LombokFocusProcessor.java index ef4c5b49..12cffea5 100644 --- a/lombok/src/main/java/io/github/eschizoid/telescope/codegen/lombok/LombokFocusProcessor.java +++ b/lombok/src/main/java/io/github/eschizoid/telescope/codegen/lombok/LombokFocusProcessor.java @@ -34,7 +34,7 @@ * guaranteed done patching. */ @SupportedAnnotationTypes({ "lombok.Data", "lombok.Value", "lombok.Builder" }) -@SupportedSourceVersion(SourceVersion.RELEASE_17) +@SupportedSourceVersion(SourceVersion.RELEASE_21) public final class LombokFocusProcessor extends AbstractTelescopeProcessor { /** diff --git a/quarkus/build.gradle.kts b/quarkus/build.gradle.kts index 67a35018..5d81728e 100644 --- a/quarkus/build.gradle.kts +++ b/quarkus/build.gradle.kts @@ -41,7 +41,7 @@ java { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" options.compilerArgs.addAll(listOf("-Xlint:all,-processing", "-parameters")) } diff --git a/spring-boot-starter/build.gradle.kts b/spring-boot-starter/build.gradle.kts index ee078673..73550aa1 100644 --- a/spring-boot-starter/build.gradle.kts +++ b/spring-boot-starter/build.gradle.kts @@ -24,7 +24,7 @@ java { } tasks.withType().configureEach { - options.release = 17 + options.release = 21 options.encoding = "UTF-8" options.compilerArgs.addAll(listOf("-Xlint:all,-processing", "-parameters")) } From 7624f8ad40b745224f1d58069266d434fb6b07ca Mon Sep 17 00:00:00 2001 From: mariano Date: Wed, 8 Jul 2026 23:32:38 -0500 Subject: [PATCH 2/4] =?UTF-8?q?docs(core):=20review-fleet=20polish=20?= =?UTF-8?q?=E2=80=94=20final=20pattern=20bindings,=20truthful=20dispatch?= =?UTF-8?q?=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - final on the record-pattern bindings in Validated.combine / ValidatedK.map2, matching the convention the other pattern sites already follow. - BridgeProcessor's sealed-dispatch comment and BridgeProcessorTest's DisplayName described a pattern-match switch; the emission is the Match.of(...).when(...).exhaustive() fluent — both now say so. - DeepMapBranchTest javadoc reworded to describe the current routing shape in its own terms (sequential instanceof passes vs the compiler-checked sealed switch in fieldIsoOf). --- .../telescope/codegen/BridgeProcessor.java | 6 +++--- .../telescope/codegen/BridgeProcessorTest.java | 4 +++- .../eschizoid/telescope/effects/Validated.java | 9 ++++++--- .../telescope/runtime/instances/ValidatedK.java | 8 ++++---- .../eschizoid/telescope/DeepMapBranchTest.java | 14 +++++++------- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/codegen/src/main/java/io/github/eschizoid/telescope/codegen/BridgeProcessor.java b/codegen/src/main/java/io/github/eschizoid/telescope/codegen/BridgeProcessor.java index e98ed2d7..78a0301b 100644 --- a/codegen/src/main/java/io/github/eschizoid/telescope/codegen/BridgeProcessor.java +++ b/codegen/src/main/java/io/github/eschizoid/telescope/codegen/BridgeProcessor.java @@ -1788,9 +1788,9 @@ private void generate( // Sealed-source bridge: dispatch on the permits clause and delegate each case to the per-case // bridge that the user already declared with @Bridge on the subtype. The emitted forward/backward - // are pattern-match switches over the sealed permits; no field walking, no rebuild — just one - // dispatch arm per case. Requires every permit case to be @Bridge-annotated and its target to be - // a permit of the sealed target. + // dispatch through the Match.of(...).when(...).exhaustive() fluent over the sealed permits; no + // field walking, no rebuild — just one dispatch arm per case. Requires every permit case to be + // @Bridge-annotated and its target to be a permit of the sealed target. private void generateSealed( final TypeElement source, final TypeElement target, diff --git a/codegen/src/test/java/io/github/eschizoid/telescope/codegen/BridgeProcessorTest.java b/codegen/src/test/java/io/github/eschizoid/telescope/codegen/BridgeProcessorTest.java index d9afe956..d607cfc0 100644 --- a/codegen/src/test/java/io/github/eschizoid/telescope/codegen/BridgeProcessorTest.java +++ b/codegen/src/test/java/io/github/eschizoid/telescope/codegen/BridgeProcessorTest.java @@ -1440,7 +1440,9 @@ public record Src(String id) {} class SealedRoots { @Test - @DisplayName("sealed interface ↔ sealed interface emits a switch over permits that delegates to per-case bridges") + @DisplayName( + "sealed interface ↔ sealed interface emits an exhaustive Match over permits that delegates to per-case bridges" + ) void sealedToSealed() { final var compilation = compile( source( diff --git a/core/src/main/java/io/github/eschizoid/telescope/effects/Validated.java b/core/src/main/java/io/github/eschizoid/telescope/effects/Validated.java index f672daca..f9f9ece5 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/effects/Validated.java +++ b/core/src/main/java/io/github/eschizoid/telescope/effects/Validated.java @@ -287,14 +287,17 @@ static Validated combine( final Validated right, final BiFunction f ) { - if (left instanceof Invalid(List leftErrors) && right instanceof Invalid(List rightErrors)) { + if ( + left instanceof Invalid(final List leftErrors) && + right instanceof Invalid(final List rightErrors) + ) { final var combined = new ArrayList(leftErrors.size() + rightErrors.size()); combined.addAll(leftErrors); combined.addAll(rightErrors); return new Invalid<>(combined); } - if (left instanceof Invalid(List errors)) return new Invalid<>(errors); - if (right instanceof Invalid(List errors)) return new Invalid<>(errors); + if (left instanceof Invalid(final List errors)) return new Invalid<>(errors); + if (right instanceof Invalid(final List errors)) return new Invalid<>(errors); final var l = ((Valid) left).value(); final var r = ((Valid) right).value(); return new Valid<>(f.apply(l, r)); diff --git a/core/src/main/java/io/github/eschizoid/telescope/runtime/instances/ValidatedK.java b/core/src/main/java/io/github/eschizoid/telescope/runtime/instances/ValidatedK.java index 7d3ee1df..26c0bba8 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/runtime/instances/ValidatedK.java +++ b/core/src/main/java/io/github/eschizoid/telescope/runtime/instances/ValidatedK.java @@ -65,16 +65,16 @@ public Kind, C> map2( final var va = unbox(fa); final var vb = unbox(fb); if ( - va instanceof Validated.Invalid(List errorsA) && - vb instanceof Validated.Invalid(List errorsB) + va instanceof Validated.Invalid(final List errorsA) && + vb instanceof Validated.Invalid(final List errorsB) ) { final var combined = new ArrayList(errorsA.size() + errorsB.size()); combined.addAll(errorsA); combined.addAll(errorsB); return box(Validated.invalid(combined)); } - if (va instanceof Validated.Invalid(List errors)) return box(Validated.invalid(errors)); - if (vb instanceof Validated.Invalid(List errors)) return box(Validated.invalid(errors)); + if (va instanceof Validated.Invalid(final List errors)) return box(Validated.invalid(errors)); + if (vb instanceof Validated.Invalid(final List errors)) return box(Validated.invalid(errors)); final var a = ((Validated.Valid) va).value(); final var b = ((Validated.Valid) vb).value(); return box(Validated.valid(f.apply(a, b))); diff --git a/core/src/test/java/io/github/eschizoid/telescope/DeepMapBranchTest.java b/core/src/test/java/io/github/eschizoid/telescope/DeepMapBranchTest.java index f62a5a24..4a359380 100644 --- a/core/src/test/java/io/github/eschizoid/telescope/DeepMapBranchTest.java +++ b/core/src/test/java/io/github/eschizoid/telescope/DeepMapBranchTest.java @@ -16,15 +16,15 @@ import org.junit.jupiter.api.Test; /** - * Pins the instanceof / record-pattern branches in {@link DeepMap} that the Java 17 cross-compile - * (PR #80) rewrote — specifically the post-fixup row routing inside {@code applyForward} / {@code - * applyBackward} (covering {@code Constant}, {@code Compute}, {@code TelescopeToTelescope} zip / - * broadcast, and {@code FromTelescopeTo}), and the {@code DefaultWriteHint} record-pattern site in - * {@code extractDefaultStrategy}. + * Pins the row-routing branches in {@link DeepMap}'s post-fixup passes — {@code applyForward} / + * {@code applyBackward} (covering {@code Constant}, {@code Compute}, {@code TelescopeToTelescope} + * zip / broadcast, and {@code FromTelescopeTo}) — and the {@code DefaultWriteHint} record-pattern + * site in {@code extractDefaultStrategy}. * *

The base {@code TelescopeTest} / {@code MappingTest} fixtures cover the common rows; this file - * targets the specific branches whose codecov coverage degraded when the sealed-switch - * exhaustiveness check moved from compile-time (pre-#80) to runtime-fallthrough (post-#80). + * targets the branches that route sequentially through instanceof chains (no compiler-checked + * exhaustiveness there, unlike the sealed switch in {@code fieldIsoOf}), so each arm needs an + * explicit pin. */ class DeepMapBranchTest { From 2523712cbe47bb3b031f9c5d03aff8d5aa3ac23c Mon Sep 17 00:00:00 2001 From: mariano Date: Wed, 8 Jul 2026 23:39:55 -0500 Subject: [PATCH 3/4] fix(core): explicit case null in combineAll + current baseline in the order-jpa header - combineAll's sealed switch now names the failure: a null element throws an NPE with an API-level message instead of the bare selector NPE. Loud-failure behavior unchanged. - order-jpa module header updated to the --release 21 / toolchain 25 split. --- .../java/io/github/eschizoid/telescope/effects/Validated.java | 1 + examples/springboot/order-jpa/build.gradle.kts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/io/github/eschizoid/telescope/effects/Validated.java b/core/src/main/java/io/github/eschizoid/telescope/effects/Validated.java index f9f9ece5..40926e39 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/effects/Validated.java +++ b/core/src/main/java/io/github/eschizoid/telescope/effects/Validated.java @@ -262,6 +262,7 @@ static Validated> combineAll(final List(); for (final var v : inputs) { switch (v) { + case null -> throw new NullPointerException("combineAll: null Validated element in inputs"); case Valid ok -> values.add(ok.value()); case Invalid bad -> errors.addAll(bad.errors()); } diff --git a/examples/springboot/order-jpa/build.gradle.kts b/examples/springboot/order-jpa/build.gradle.kts index a433e373..a27f1cad 100644 --- a/examples/springboot/order-jpa/build.gradle.kts +++ b/examples/springboot/order-jpa/build.gradle.kts @@ -4,8 +4,8 @@ plugins { id("io.spring.dependency-management") version "1.1.7" } -// Spring Boot 4.0.1 — the latest GA at the time of this demo. Brings Spring Framework 7.0, -// Jakarta EE 10 (`jakarta.persistence.*`), Hibernate 7, and Java 17 baseline. We compile to 25. +// Spring Boot 4.x — brings Spring Framework 7.0, Jakarta EE 10 (`jakarta.persistence.*`), and +// Hibernate 7. This module compiles with --release 21 on the Java 25 toolchain. group = "io.github.eschizoid.telescope.demo" version = "0.0.1-SNAPSHOT" From da7c09bfab962e7fc0424e4c9307ee30062174c6 Mon Sep 17 00:00:00 2001 From: mariano Date: Thu, 9 Jul 2026 06:40:07 -0500 Subject: [PATCH 4/4] docs(core): pin combineAll's loud null-element failure The null element behavior is advertised as a deliberate change; the new test asserts the NPE fires with the API-naming message rather than a silent element drop. --- .../telescope/effects/SealedEffectsBranchTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/core/src/test/java/io/github/eschizoid/telescope/effects/SealedEffectsBranchTest.java b/core/src/test/java/io/github/eschizoid/telescope/effects/SealedEffectsBranchTest.java index 9dab43b8..2e67eadd 100644 --- a/core/src/test/java/io/github/eschizoid/telescope/effects/SealedEffectsBranchTest.java +++ b/core/src/test/java/io/github/eschizoid/telescope/effects/SealedEffectsBranchTest.java @@ -3,10 +3,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import io.github.eschizoid.telescope.runtime.instances.EitherK; import io.github.eschizoid.telescope.runtime.instances.ValidatedK; +import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -274,6 +276,14 @@ void mixedInvalidWins() { void emptyInputs() { assertEquals(Validated.valid(List.of()), Validated.combineAll(List.of())); } + + @Test + @DisplayName("a null element fails loudly with a message naming the API, never a silent drop") + void nullElementFailsLoudly() { + final var inputs = Arrays.asList(Validated.valid(1), null); + final var thrown = assertThrows(NullPointerException.class, () -> Validated.combineAll(inputs)); + assertEquals("combineAll: null Validated element in inputs", thrown.getMessage()); + } } @Nested