Skip to content

build: migrate obp-api and obp-commons to Scala 2.13 - #2890

Open
hongwei1 wants to merge 37 commits into
OpenBankProject:developfrom
hongwei1:develop-obp
Open

build: migrate obp-api and obp-commons to Scala 2.13#2890
hongwei1 wants to merge 37 commits into
OpenBankProject:developfrom
hongwei1:develop-obp

Conversation

@hongwei1

Copy link
Copy Markdown
Contributor

Moves obp-api and obp-commons from Scala 2.12.21 to 2.13.18.

What changes for anyone deploying this

The artifacts now require JDK 25 at runtime. On 2.12 the compiler emitted Java 8 class files whatever -release said, so the setting only widened the visible API surface. 2.13 honours it, and this build targets 25. Anything loading these jars on an older JVM fails at class load with UnsupportedClassVersionError rather than at first call. The Dockerfiles and CI are already on eclipse-temurin:25; a deployment running the jars on its own JVM has to be too.

Two dependency swaps are visible to anyone assembling their own artifact:

  • cglib → byte-buddy for the three Connector proxies. cglib 3.3.0 bundles ASM 7.1, which reads class files only up to major 57; 2.13 with -release 25 emits major 69, so every proxy would fail to generate.
  • scalacache 0.9.3 → 0.28.0, which changes how a failed Redis decode is reported — the codec returns a value instead of throwing, and it is still treated as a cache miss.

Also: scalapb 0.8.4 → 0.9.0 (first release with a _2.13 artifact), scalameta 3.7.4 → 4.1.12, -Ypartial-unification removed (on by default in 2.13), and the paradise compiler plugin replaced by -Ymacro-annotations.

External artifacts. lift-persistence and scala-macros are consumed as _2.13 builds from JitPack; both resolve publicly today (verified). scala-macros is now pinned to the macros module rather than the aggregate — the aggregate's pom hardcodes _2.12, which would put two Scala versions of the same classes on the classpath.

No API change

Endpoint behaviour, request and response shapes, and error codes are unchanged. Per-version test counts match the 2.12 baseline.

The documentation of response shapes is corrected in 74 typed bodies (resource-docs/swagger). 2.12 described a bare-List body by reflecting over the cons cell — publishing its head/tl internals — described nested collection fields as empty objects, and lost enumeration members. All three now read {"type":"array", ...} with the element's real schema. The responses themselves did not move; only their published description did, from wrong to right.

Verification

  • Local sharded suite: 3439 tests, 0 failures
  • CI: 26/26 checks, all shards green
  • Consent end-to-end (UK v3.1 / v4.0.1, Berlin Group, OBP native, OBP VRP, cross-standard isolation): 109 checks, 0 failed
  • Consumer-contract suite diffed against a 2.12 baseline: 0 breaking changes; the 74 documentation corrections reviewed one by one against the baseline, 0 regressions
  • Merging the current upstream develop (9 commits: chat, password-policy, signal) into this branch compiles under 2.13 with 0 errors

Notes for review

The migration surfaced defects that predate it and are fixed here — a refutable pattern in a val definition that threw MatchError on any non-None bank attributes, protobuf null string fields, a discarded assertion, and a no-op Objects.nonNull guard. Each is a separate commit with its reasoning.

Three of the schema fixes are worth a careful look, because the failure mode is subtle: type Coll[T] moved from GenTraversableLike to IterableOnce, and 2.13's Option implements IterableOnce where 2.12's did not — so every Coll[X] type test also answered true for Option[X], and every optional field whose Coll case was reached first was published as an array of itself.

First step of the Scala 2.12 -> 2.13 migration: pay the language-level debt
while still on 2.12, so the version flip carries as little as possible.

-Xsource:2.13 makes the 2.12 compiler apply 2.13 language semantics. It reports
zero errors on this tree and 110 new warnings, all fixed here:

- 108 sites (one per file) of `new Inject(buildOne _)`. 2.13 no longer converts
  a method without a parameter list to a function via `m _`; the replacement is
  the explicit literal `() => buildOne`. Two further occurrences live in
  commented-out code and were converted as well, so uncommenting them later
  cannot reintroduce the construct.
- API1_2_1Test: the wildcard import of the commons model brought in an
  ErrorMessage that the same-package code.api.v1_2_1.ErrorMessage shadows. Both
  are case class ErrorMessage(code: Int, message: String), so resolution never
  changed behaviour; the import now excludes the name to say which one is meant.
- AccountTagTest: `tags.exists(...) equals true` computed a Boolean and threw it
  away, so the assertion never asserted anything. It is now `should equal(true)`
  and passes, meaning the condition held all along.

Note the flag's boundary: it covers language semantics only. It does not report
uses of collection types that 2.13 removes outright, which are handled
separately.

The flag has to be listed in both poms. obp-api declares its own <args>, and
Maven replaces rather than merges that list, so an entry in the parent pom alone
would not reach this module.

Verification: mvn clean compile test-compile is BUILD SUCCESS, and the three
warning categories above go from 110 to 0. Measuring that needed -Xmaxwarns
raised for the run: scalac defaults to 100 warnings and obp-api's main sources
sit above that, so the default output silently truncates and undercounts. The
full local suite passes with no FAILED, RUN ABORTED or SUITE ABORTED markers.
cglib 3.3.0 bundles ASM 7.1, which reads class files only up to major version
57. Scala 2.13 with -release 25 emits major 69, so all three generated Connector
proxies would fail the moment the compiler is switched. byte-buddy 1.18.11 knows
class file versions up to JAVA_V27. Doing the swap before the flip keeps the two
failures separable.

None of the three interceptors calls the superclass implementation, so cglib's
MethodProxy argument was unused and java.lang.reflect.InvocationHandler is a
direct replacement via InvocationHandlerAdapter. The generation itself is now in
ConnectorProxy rather than written out three times.

Two details that the rewrite turns on:

- All three interceptors forward with `method.invoke(target, args: _*)`, which
  throws on a null array. java.lang.reflect.Proxy passes null for a method that
  declares no parameters; cglib passes an empty array. InvocationHandlerAdapter
  builds the array from the method's parameter list, so no-argument methods get a
  zero-length array and the forwarding stays valid.
- The interceptors are anonymous classes, not lambdas: Scala does not apply SAM
  conversion to InvocationHandler and types the lambda as a Function3 instead.
  That moves what `this` means, so InternalConnector's `this.callableMethods` is
  now qualified as `InternalConnector.this.callableMethods` - unqualified it
  would have resolved to the handler.

ProxyConnectorTest is new. ConnectorUtils.proxyConnector is registered as the
"proxy" connector and its own comment says it exists for unit tests, but neither
the string "proxy" nor proxyConnector appeared anywhere under src/test and no
props file selects it, so the one interceptor of the three with no coverage was
also the one whose $default$ handling differs. The test pins delegation, the
no-argument path, a $default$ accessor, and a call whose result carries an
InBound DTO through the field-stripping branch. It was written and made to pass
against cglib first, then rerun unchanged against byte-buddy.

The other two proxies are covered by the existing suite: test.default.props sets
connector=star, so every integration test goes through the package-object proxy,
and starConnector_supported_types includes internal.

cglib names survive in dynamic_code_sandbox_permissions, and that is deliberate:
those entries grant reads of cglib system properties to dynamically compiled
code, the proxies are built in lazy val initialisers rather than inside the
sandbox, and System.setSecurityManager has been permanently disabled since JDK
24, so the list has no effect on this change either way.

Verification: ProxyConnectorTest passes 4/4 under both libraries, and the full
local suite passes with no FAILED, RUN ABORTED or SUITE ABORTED markers.
…acts

Wires in the two prerequisite releases. Both still resolve to their 2.12
artifacts here, so this is verifiable now rather than only at the version flip.

lift.version v1.0.4 -> v1.0.5. v1.0.5 is the first tag of that project published
for both Scala versions: JitPack builds sbt projects with `sbt clean publishM2`,
without the `+`, so every earlier tag shipped lift-persistence_2.12 and nothing
else, whatever its number. Fetching the pom and jar for both suffixes confirms
v1.0.5 has them; JitPack's maven-metadata.xml cannot be used for that check
because it lists versions that do not exist.

scala-macros moves from the aggregate com.github.OpenBankProject:scala-macros to
the com.github.OpenBankProject.scala-macros:macros_${scala.version} module at
v1.0.0-alpha.4. Only com.tesobe.CacheKeyFromArguments and com.tesobe.CacheKeyOmit
are used from that project and both are in the macros module, so the aggregate
was also pulling in a core module nothing references. More to the point, the
aggregate cannot survive the flip: the v1.0.0-alpha.3 pom hardcodes the _2.12
artifacts, and an already-published pom cannot be changed, while v1.0.0-alpha.4's
lists both _2.12 and _2.13 and would put two Scala versions of the same classes
on the classpath. Naming the module directly makes the suffix follow
${scala.version} like every other Scala dependency in this pom.

Verification: `mvn dependency:tree` resolves lift-persistence_2.12:v1.0.5 and
macros_2.12:v1.0.0-alpha.4 with no core module, and every one of the 88
Scala-suffixed artifacts in the tree is _2.12 - no second Scala version anywhere.
The full local suite passes with no FAILED, RUN ABORTED or SUITE ABORTED markers.
Both constructs still compile on 2.13 - they are deprecated there and removed
only in Scala 3 - so this is debt paid down while the tree is quiet rather than
a blocker for the flip.

51 procedure definitions across 28 files gain an explicit `: Unit =`. A body in
braces with no `=` is the same method either way, so nothing changes but the
spelling.

The seven view bounds become the implicit parameter they desugar to:
`[T, D <% T: TypeTag]` is `[T, D: TypeTag](implicit ev: D => T)`. This is the
riskier half, because Converter's whole purpose is implicit conversion and its
own comment warns about clashing with Predef.$conforms - the concern being that
writing the evidence out could change which implicit is found. It does not: the
tree compiles with no errors and not one of the 62 Converter use sites needed a
change, because subclasses already had to supply exactly this implicit for the
view bound to be satisfied.

Verification: mvn clean compile test-compile is BUILD SUCCESS, and the full
local suite passes with no FAILED, RUN ABORTED or SUITE ABORTED markers.
…ction

scala.collection.JavaConverters is deprecated in 2.13 in favour of
scala.jdk.CollectionConverters. 31 imports change, in obp-api only, and nothing
else: every conversion at the 59 call sites is the extension-method form
(.asScala / .asJava), and there is not one use of the old named conversions such
as asScalaBuffer, so the new package covers all of them under the same names.

scala.jdk is a 2.13 package, but scala-collection-compat back-ports it to 2.12
and obp-api already declares that dependency - for this very reason, per the
comment on it. obp-commons has no converter imports and so needs nothing.

The list of .asScala call sites matters again at the flip and is worth stating
here: 2.13 makes a bare Seq mean immutable.Seq, while .asScala yields a mutable
Buffer, so these are the places where passing the result to a Seq parameter, or
splatting it with `: _*`, turns into a compile error. Nothing to do about that
now - on 2.12 both spellings behave identically - but that is the population to
look at when the flip produces Seq type errors.

Verification: mvn clean compile test-compile is BUILD SUCCESS, and the full local
suite passes with no FAILED, RUN ABORTED or SUITE ABORTED markers.
mapValues and filterKeys return a view on 2.13 rather than a Map, and the Left
and Right projections' get is deprecated. Each site is rewritten to the strict,
version-neutral form.

Most are mechanical, but two are not.

DynamicEntityInfo built a fieldNameToType map and then only ever read its key
set. mapValues is lazy, so jsonTypeMap was never actually applied; forcing it
with a .toMap would start throwing NoSuchElementException for any entity field
whose declared type is not one of the DynamicEntityFieldType names - a failure
that does not exist today and would first appear after the flip, far from its
cause. mapValues cannot change the key set, so the filter now reads the keys off
the source map and the derived map is gone. That leaves jsonTypeMap unused, so
it goes too; it was a val in the case class body, not a constructor parameter,
so it is absent from copy, equals and the JSON encoding.

The other is the dependency map that DynamicUtil.Validation builds as a source
string and compiles at class-initialisation time. It needs the .toMap for 2.13,
but the props default is an empty list, which makes the generated code `Map()`,
whose type parameters stay undetermined - and .toMap then cannot prove the
elements are pairs. The generated snippet now names the type, `Map[String,
String](`, which types on both the empty and the populated case.

That last one is worth spelling out, because the failure was expensive to read.
A compile error in that string surfaces as ExceptionInInitializerError once and
NoClassDefFoundError forever after, so every dynamic-code endpoint 500s or hangs,
the shard's embedded server goes down with it, and what the report shows is
eleven failures spread over ten suites, all of them Read timed out, connection
refused, or "Looking for Connection Identifier ConnectionIdentifier(lift) but
failed" - not one of which points at the line. The compiler cannot see this code;
only running it can.

Verification: mvn clean compile test-compile is BUILD SUCCESS, DynamicUtilTest
passes 6/6, and the full local suite passes with no FAILED, RUN ABORTED or SUITE
ABORTED markers.
The gRPC server is a running production path with no test of any kind: nothing
under src/test mentioned grpc, and no shard's test_filter names the package. That
is worth fixing on its own, and it is a prerequisite for upgrading scalapb, since
everything under code/obp/grpc/api is generated code checked into the repository
and "it still compiles" is not evidence that regenerating it kept working.

ObpGrpcServerSmokeTest starts the server, talks to it over a real socket, and
covers both branches of the auth interceptor - which had no coverage either, and
is what decides whether the port is open to the world. The authenticated call
goes through the service binding, the generated stub, and protobuf serialisation
in both directions.

Writing it turned up two defects in getBanks, both on the untested path:

- The handler destructured with `val BankJson400(id, ..., None) = bank`, a
  refutable pattern in a val definition. Any bank whose attributes are Some -
  including Some(List()), which is what the JSON factory actually produces - threw
  a MatchError that reached the client as INTERNAL. The attributes are not carried
  over the wire, so the fields are now read rather than matched on.
- logo and website are nullable, and protobuf string fields reject null, so a bank
  without either would have failed serialisation right behind the MatchError.

Neither is caused by the migration; both were simply invisible without a test.

The package is covered by the catch-all rather than by any explicit filter, so
the run was checked for it: "code.obp.grpc" appears in the catch-all extras line,
confirming the suite is actually executed rather than silently skipped.

Verification: the suite passes 2/2, and the full local run passes with no FAILED,
RUN ABORTED or SUITE ABORTED markers.
scalapb-runtime-grpc_2.13 does not exist below 0.9.0 - 0.8.4 returns 404 for that
suffix - so this is a hard prerequisite rather than a version refresh, and 0.9.0
is the smallest step that clears it.

0.9.0 narrows two members of the generated companions from
GeneratedMessageCompanion[_] to GeneratedMessageCompanion[_ <: GeneratedMessage],
which the 0.8.4-generated sources do not satisfy: bumping the runtime alone fails
with 45 errors. All 41 declarations are narrowed here, in the five directories
that hold generated code - not just code/obp/grpc, but also
code/bankconnectors/grpc/connector and the vendored well-known types under
com/google/protobuf. Locating them by the "Generated by the Scala Plugin"
header rather than by directory is what found the last two.

Patching in place rather than regenerating is deliberate. The checked-in sources
are not a clean output of the generator:

- The packages do not match the protos. api.proto declares `package
  code.obp.grpc` and chat.proto declares `code.obp.grpc.chat.g1`, while the
  checked-in code lives in code.obp.grpc.api and code.obp.grpc.chat.api.
  Regenerating adds a parallel set of packages - 41 files become 71 - instead of
  updating the existing ones.
- They have been edited by hand: ObpServiceGrpc.scala and ApiProto.scala carry a
  "Temporarily disabled ... javaDescriptor filter" change, with matching edits in
  Client.scala and ObpGrpcServer.scala. Regenerating discards all of it.

Reconciling those is real work with its own risk, and it is not what a runtime
upgrade should drag in.

scripts/regenerate_grpc.sh still lands, because the generated code having no
recorded provenance is a problem either way. It pins protoc and scalapb, caches
them under target/, and has a --check mode that regenerates into a temp
directory and diffs without touching the tree. Its header states plainly that the
checked-in sources are not reproducible from it today and why, so nobody reads a
large --check diff as breakage. It also fetches protoc directly instead of going
through scalapbc's bundled protoc-jar, which pins protoc 3.7.1 - a release with
no osx-aarch_64 build, so scalapbc cannot run on Apple Silicon at all.

Verification: BUILD SUCCESS with no errors, ObpGrpcServerSmokeTest passes 2/2
against the new runtime - a real socket, a real RPC, protobuf encoded and decoded
- and the full local suite passes with no FAILED, RUN ABORTED or SUITE ABORTED
markers.
scalameta_2.13 does not exist below 4.x - 3.7.4 returns 404 for that suffix - so
this is a prerequisite, not a refresh. No source change was needed: the ten
org.scalameta.logger.elem calls and the two `import scala.meta._` blocks compile
unchanged.

Compiling is not enough to conclude anything here, though. Both scalameta uses
parse a .scala file and walk the AST at run time, so a 3.x-to-4.x change in node
types would show up as a collect that quietly matches less - fewer results, or
none - rather than as an error. The compiler cannot see that, and one of the two
tools has no test at all: ApiRole's Util.checkWrongDefinedNames is called only
from its own main.

So it was run on both versions and the output compared: 40 lines under 3.7.4, the
same 40 lines byte for byte under 4.1.12. The other tool,
ErrorMessages.getDuplicatedMessageNumbers, is covered by
code.errormessages.DuplicatedMessages, which asserts the count is zero and runs
in the suite.

Verification: BUILD SUCCESS with no errors, the self-check output is identical
across both scalameta versions, and the full local suite passes with no FAILED,
RUN ABORTED or SUITE ABORTED markers.
None of the three has a 2.13 artifact at its current version - scala-nameof
1.0.3, chill 0.9.3 and chill-bijection 0.9.1 all return 404 for that suffix -
so each is a prerequisite rather than a refresh.

scala-nameof 1.0.3 -> 2.0.0 needs no source change at all: NameOf.scala is
byte-for-byte identical between the two releases and the package path is
unchanged, which is what lets 3,326 call sites stay as they are.

chill and chill-bijection -> 0.9.5 touch one place, the Kryo codec in
Redis.scala that serialises memoized values.

That codec is worth a note for whoever deploys this. A chill upgrade carries a
Kryo upgrade, and the entries already in Redis were written by the old one, so
some of them will fail to decode after the rollout. A test environment never
shows this, because it starts from an empty cache. It is not an outage: the codec
rethrows on a failed decode, scalacache treats a throwing read as a miss,
recomputes from the source block and repopulates the key with a valid
serialisation. The cost is a cold cache on the first request per key, not wrong
data - RedisDeserializeMissTest exists to keep that behaviour from regressing to
the old sentinel-value approach, which did serve wrong data for a whole TTL.

Verification: BUILD SUCCESS with no errors, RedisDeserializeMissTest and
MethodRoutingCacheInvalidationTest pass 5/5, and the full local suite passes with
no FAILED, RUN ABORTED or SUITE ABORTED markers.
scalacache-redis_2.13 and scalacache-guava_2.13 do not exist below 0.28.0, and
that release is also the first one published for 2.13 at all, so there is no
gentler intermediate step to take.

0.28 types its Cache by the value type, while all four wrappers are generic in A
and the old ScalaCache instance was untyped. The cache is therefore built per
call rather than held as a field. RedisCache and GuavaCache are thin wrappers
over the pool and the Guava instance built at startup - neither opens anything of
its own - so the connection pool, its authentication and its SSL configuration
stay shared, which is the property the comment on the pool exists to protect.
Guava's underlying store is declared at Entry[Any] and narrowed per call; the
cast is erased at run time, and a key always holds the type its own call site
wrote, which is what the untyped ScalaCache already assumed.

The codec API changed shape too: deserialize threw, decode returns
Left(FailedToDecode). The self-healing contract is unchanged but its machinery
moved, and the move is easy to misread. RedisCacheBase.doGet raises the Left,
which on its own would mean a corrupt entry fails the caller for a whole TTL.
AbstractCache._caching - the path memoize takes - wraps that read in
handleNonFatal and substitutes None, so a failed decode is still a miss: the
source block runs and the key is rewritten. Reading only doGet gives the opposite
answer, so the reasoning is recorded next to the codec.

That mattered more than usual here because the chill upgrade in the previous
commit guarantees some entries written by the old Kryo will not decode after a
rollout. Cold cache, not errors - but only because of the layer above.

InMemoryCachingTest is new, and was written and made to pass against 0.9.3 before
any of this. The Guava backend had no test at all while six business call sites
went through it. Two of its scenarios pin the derived key's shape rather than
hit/miss behaviour: scalacache builds the key from the enclosing method and its
non-excluded arguments, which is why the caller's string ends up inside it and
why NewStyle's deleteKeysByPattern("*getMethodRoutings*") matches anything. A
change in key derivation would leave the cache caching and the invalidation
silently matching nothing.

RedisDeserializeMissTest is rewritten against decode/encode, asserting the same
intent: a corrupt entry must never be served as a valid hit.

Verification: BUILD SUCCESS with no errors; InMemoryCachingTest,
RedisDeserializeMissTest and MethodRoutingCacheInvalidationTest pass 11/11 -
including both key-shape scenarios and the getMethodRoutings pattern - and the
full local suite passes with no FAILED, RUN ABORTED or SUITE ABORTED markers.
2.13 removes TraversableLike, SeqLike, GenSetLike, GenTraversableOnce,
GenTraversableLike, CanBuildFrom and breakOut outright. -Xsource:2.13 does not
report any of it - that flag covers language semantics, not the library - so
these sites would first surface as a wall of errors during the version flip,
mixed in with everything else it produces. Rewritten here instead, on 2.12, where
each one can be judged on its own.

Functions.Implicits.RichCollection is the substantial one. It moves from
TraversableLike + CanBuildFrom to the collection type itself plus
scala.collection.Factory, which 2.13 has natively and scala-collection-compat
back-ports to 2.12, so one source compiles on both. obp-commons now declares that
dependency; obp-api already had it.

The result type narrows with it. CanBuildFrom[Repr, A, That] let the result be a
different kind of collection from the source, and no call site used that:
distinctBy returns the List it was given, classify splits a Seq into two Seqs, ?+
returns the List it was given. Fixing the result at C[A] keeps every call
compiling and drops a degree of freedom that only made the rewrite harder.

Elsewhere the removed types are replaced by Iterable, deliberately rather than by
IterableOnce. IterableOnce is the closer match to GenTraversableOnce, but the
branches in bankconnectors call isEmpty and nonEmpty, and 2.13 puts those on
IterableOps and not on IterableOnce - so IterableOnce would compile here and fail
at the flip, which is the exact failure this step exists to prevent. Nothing
reaching those branches is an Iterator: connector methods return List, Seq or
Set, and the neighbouring branch already matched on Traversable.

SwaggerJSONFactory's Coll alias feeds reflective subtype tests against declared
field types, all of which are Iterables, so the tests keep selecting the same
fields. Its breakOut becomes a collected sequence handed to ListMap - .to(ListMap)
was tried first and does not preserve the target type through compat on 2.12.
Order is unchanged: the sortBy still fixes it and ListMap preserves insertion
order.

FunctionsTest gains six scenarios, written and made to pass against the old
implementation first. It covered ?+, ?-, distinctBy, findByType, deepFlatten and
BinaryOp but not classify - which is what validateRequiredFields in
code.bankconnectors uses to split validation results - nor toMapByKey,
toMapByValue or notExists.

Verification: BUILD SUCCESS with no errors, FunctionsTest passes 12/12 both
before and after the rewrite, and the full local suite passes with no FAILED, RUN
ABORTED or SUITE ABORTED markers.
First half of the version flip. It cannot stand on its own: com.tesobe:obp-commons
carries no Scala suffix, so a 2.13 build of it is not consumable by a 2.12
obp-api, and the version properties both modules read live in the parent pom.
The next commit moves obp-api and the pair is what has to be reviewed and
reverted together.

scala.version 2.12 -> 2.13, scala.compiler 2.12.21 -> 2.13.18. Alongside that:
-Ypartial-unification goes, since 2.13 rejects it as an unknown option and does
that work by default; the paradise compiler plugin goes, replaced by the
-Ymacro-annotations flag it became.

scalac.release stays at 25, against the migration plan, which wanted it held at 8
so the language version and the bytecode version moved in separate revertible
commits. That is not achievable. On 2.12 the flag only widened the visible API
surface; on 2.13 it is enforced, and the sources call String.isBlank (Java 11) in
five places and java.io.ObjectInputFilter (Java 9) in
BankAccountCreationDispatcher, where it filters deserialization. At -release 8
neither is visible and the build fails. Trading a deserialization filter for a
tidier commit split is not a trade worth making, so the axes move together and
the pom records why.

Eight source errors, in three shapes:

- Six postfix operators - `exists(ele ==)`, `filterNot(null ==)`, `} toList` -
  which 2.12 reported as warnings under -feature and 2.13 rejects. They are now
  ordinary lambdas and dotted calls. One knock-on: with `toList` no longer
  postfix, an infix `collect` bound the call to the block rather than to its
  result, so that one is dotted too.
- BigInt.intValue() twice: parameterless on 2.13, so the parens applied () to an
  Int.

Verification: obp-commons compiles clean, and the full suite result is recorded
on the obp-api commit that follows, since neither half runs alone.
Second half of the flip, and the half that has to be read together with the
first: obp-commons has no Scala suffix in its coordinates, so the two modules
cannot be on different Scala versions even for one commit.

Fifty compile errors, and the ones worth naming are the ones that were already
wrong before 2.13 refused them.

List no longer extends Product. Six ResourceDocs passed a bare List as the
example body, and the docs machinery walks that value with productIterator - so
those endpoints have been documenting a List's product elements, `head` and `tl`,
as if they were API fields. APIUtil.jArrayBodyOf now wraps them in the JArrayBody
that already existed for array-shaped bodies, which keeps the rendered example an
array and gives the field table something real. Widening the parameter to Any was
tried first and reverted: getAllFields genuinely calls productIterator, so the
bound is load-bearing rather than nominal.

The runtime-compiled connector methods all failed with "identifier expected but
':' found". InternalConnector builds each method's signature by string surgery on
`methodSymbol.typeSignature.toString`, which renders as "(params)ReturnType" on
2.12 and "(params): ReturnType" on 2.13, so appending a colon produced two. It is
render-agnostic now, and deliberately emits no space after the colon because the
regexes below it match ")\s*:" immediately followed by the type name - the first
fix added a space and broke the pattern matching instead.

SwaggerJSONFactory's Coll alias became IterableOnce rather than Iterable. With
Iterable, the reflective subtype tests walked 2.13's much deeper collection base
graph and SwaggerFactoryUnitTest died with a StackOverflowError inside
scala-reflect's FindMembers. Runtime matches that call head or nonEmpty use
Iterable directly, since IterableOnce has neither.

translateEntity no longer reflects into collections. 2.13's Nil carries a static
EmptyUnzip of (Nil, Nil), so walking a List's fields arrives back at Nil for ever;
JArray was already excluded for the same kind of reason.

Two guards that never guarded: `case Unit` matched the Unit companion object,
which reflectively invoking a Unit-returning method never produces, so that arm
was dead - it matches BoxedUnit now. And `Objects.nonNull(exampleValue)` returns a
Boolean and discards it, so a null example reached JsonUtils.getType and its own
requireNonNull threw; it is a real check now, describing an unknown example as a
plain object.

The rest are mechanical: 13 breakOut calls in the checked-in generated gRPC code,
map's second type parameter, Java's protected childValue which no Scala source can
name, `Unit` used as a value, more postfix operators, and an implicit needing an
explicit type.

Two test fixtures are regenerated rather than edited. They are Java-serialized
Scala collections written under 2.12 and unreadable on 2.13, which left the frozen
connector contract null and failing with NPEs. Regenerating captures the same
contract: every phase-1 step ran the full suite green on 2.12 with the old
fixtures, including these two, and nothing since has touched a connector DTO.

run_tests_parallel.sh installs obp-commons with -am so the parent pom goes in with
it. Without that the local repository holds a 2.13 obp-commons beside a 2.12
parent, obp-api resolves its dependencies through the stale parent, and _2.12
artifacts land on the classpath next to 2.13 classes. Nothing detects it - the
build succeeds and the suite dies at run time with ClassNotFoundException:
scala.Serializable.

Verification: the full local suite passes with no FAILED, RUN ABORTED or SUITE
ABORTED markers - 581 unit/pure and 2813 integration, with every per-version count
matching the 2.12 baseline.
regenerate_grpc.sh fetches protoc and scalapbc with curl -L and then runs them.
-L follows a redirect to http:// just as readily as to https://, so a downgraded
redirect could substitute what the script executes. --proto '=https' restricts
the transfer and its redirects to https, and --tlsv1.2 sets a floor.

Raised by SonarCloud on this PR (shell:S6506, two mediums), and worth fixing
rather than waiving given the fetched files are executables.
… first

Every one was written as a failing test before it was fixed, and two of those
tests changed what the fix had to be.

ConnectorProxy intercepted with ElementMatchers.any(), which covers Object's
methods as well as Connector's. For two of the three handlers that was harmless,
because they forward with method.invoke(delegate, ...). InternalConnector's reads
any unrecognised name as a dynamic connector method to look up and compile, so
toString, hashCode and equals on that proxy all threw IllegalStateException -
logging the connector, interpolating it into a string or using it as a map key
blew up. Object's methods are excluded now, which also gives the proxies ordinary
identity semantics. ConnectorProxyObjectMethodsTest covers all of it, including
that ConnectorUtils.proxyConnector was never affected.

ObpGrpcServerSmokeTest bound the single configured gRPC port while every other
server-starting suite here takes its port from the shard, because shards are
parallel JVMs. The first attempt to reproduce this was wrong: occupying the port
from another process did not stop the server, since the occupying socket had
SO_REUSEADDR and macOS allowed the second bind. Running two copies of the suite
concurrently did reproduce it - one aborts with BindException. The port is a
constructor parameter now, defaulted to the configured one, and the test asks the
OS for a free one. Two concurrent runs both pass.

jArrayBodyOf is removed, and this is the one where the test showed the original
diagnosis was wrong. The claim was that a bare List documented `head` and `tl` as
API fields because 2.12's List was a Product. getAllFields has always had a
branch for a root-level list, and although List is not a Product in 2.13, a
non-empty one is a `::` - a case class, so a Product at run time. Lists were
being handled correctly; those two names came from a nested list field. The
replacement made things worse: the array body described eight json4s internals -
MODULE$, EmptyUnzip, arr, obj - instead of the entity's four fields. The six
ResourceDocs pass their lists again, and getAllFields takes Any and unwraps a
root-level collection through .head rather than productIterator.toSet.head, which
picked from a two-element set by hash and could return the tail.

One knock-on: a complexNestedClass assertion expected a field declaration with a
trailing ", ", which asserts that field is not last in the rendered list. The
reordering broke it. It joins the assertions rewritten earlier in this branch to
name the fields rather than pin the rendering.

Verification: the full local suite passes with no FAILED, RUN ABORTED or SUITE
ABORTED markers - 585 unit/pure and 2818 integration, the increase being the new
tests.
…futed

ConnectorProxy's earlier fix excluded Object's methods, which was the shape of
the problem rather than its extent. Connector extends Helper.MdcLoggable, and
that contributes public abstract interface methods - logger, clazzName, a default
initiate - declared by MdcLoggable and so untouched by an Object exclusion. They
still reached InternalConnector's handler, which reads an unrecognised name as a
dynamic connector method to look up and compile, and threw.

Narrowing the matcher to Connector's own methods, which was the obvious next
step, does not work: those members are abstract, so something must implement them
or the generated class cannot be instantiated. The fix belongs in the handler,
which now sends anything outside the Connector API to the empty Connector object
that already backs the $default$ accessors. ConnectorProxyObjectMethodsTest gains
a shape check over every zero-argument method the interface inherits, so the next
trait mixed into Connector is covered without anyone having to remember.

The gRPC smoke test asked the OS for a free port, closed it, then bound it. That
window is the flaw of the idiom rather than a fix for it. It passes 0 to grpc-java
and reads ObpGrpcServer.boundPort back instead; start() logs the bound port too,
which was previously the requested one.

getAllFields folds every element of a root-level collection rather than
describing only the head, so a heterogeneous list documents all of it.

JSONFactory1_4_0_LightTest gets its list scenario back. It was removed on the
theory that a List documented head and tl, which the previous round disproved -
getAllFields has always had a collection branch, and a non-empty List is a `::`,
a Product at run time. The comment left in its place asserted that refuted
diagnosis and pointed at jArrayBodyOf, deleted two commits ago.

code/bankconnectors/grpc/connector - four files, 360 lines - is deleted. It
arrived with the scalapb upgrade as a second copy of the connector service under
connector.proto's package name, and nothing referenced it: GrpcUtils imports the
same types from grpc.api. It compiled into the jar carrying its own bindService.

One reported defect is not fixed, because writing the test showed the report was
wrong. getAllFields answering Nil for a value it cannot describe was called a
silent failure, and rejecting one looked like the fix; it took out five suites.
The method recurses, so it legitimately reaches scalars - the elements of a
List[String] field, a null - which have no fields to report. Nil is the correct
answer there. What is true is that widening the parameter to Any gave up a
compile-time check across every ResourceDoc, and that belongs at the call site
rather than inside a field walker; the comment now says so instead of claiming
the bound never bought anything.

Verification: the full local suite passes with no FAILED, RUN ABORTED or SUITE
ABORTED markers - 586 unit/pure and 2820 integration - and two concurrent runs of
the gRPC suite both pass with no bind error.
boundPort read server.getPort and fell back to the constructor argument once
stop() nulled the field, which is 0 for a server given an ephemeral port - so
after shutdown it reported a port the server had never listened on. The port is
now recorded at start() and held in a volatile field, which also publishes it to
the threads that read it. The smoke test asserts it across stop(); it failed
with "0 did not equal 53257" before the change.

regenerate_grpc.sh still fed connector.proto to protoc. That proto declares
package code.bankconnectors.grpc and scalapb appends the file name, so
generating from it writes code.bankconnectors.grpc.connector - the 360-line
duplicate of the connector service that came in with the scalapb upgrade and was
removed again, while GrpcUtils takes those types from the hand-written .api
package. Excluded, with the reconciliation it needs written down.

InternalConnector decided what dynamic code may implement by asking
methodNameToSignature, a map built by filtering out vals and vars - so besides
the inherited members it also excludes Connector's own vals, messageDocs among
them, and routes them to the empty stub. The criterion now has a name and states
that; a scenario pins that messageDocs answers, and answers stably.

getAllFields folded the root-collection branch with acc ++ getAllFields(e),
rebuilding the accumulator once per element. It flatMaps over the iterator now,
which is linear and keeps first-seen order.

The proxy shape check invoked every zero-arg inherited method, MdcLoggable's
initiate() among them - a lifecycle hook Boot overrides with real work. Connector
leaves it a no-op, so the invoke is safe today; that is now asserted before the
loop rather than assumed.
StarConnector threw NullPointerException on every member Connector inherits
rather than declares. Its handler recognised the $default$ accessors and sent
everything else into MethodRouting resolution, where it zips parameter names
with args - and InvocationHandlerAdapter passes null, not an empty array, for a
method that declares no parameters. So logger, clazzName and initiate all threw,
the same hole InternalConnector had for the same reason.

The rule now lives once, as ConnectorProxy.isInheritedMember, and both handlers
ask it; it was being rediscovered one proxy at a time. The scaladoc that claimed
InvocationHandlerAdapter hands over a zero-length array is corrected: forwarding
survives the null because Method.invoke reads a null array as no arguments,
which is why the proxy connector was never affected.

ObpGrpcServer.stop() shut down ChatEventBus, LogCacheEventBus and
MetricsEventBus whether or not this instance had started them. Each is an object
holding one subscriber connection for the process and start() is a no-op once one
is running, so a second server's stop() closed the pub/sub the first was still
serving from. start() now records which buses it actually started and stop()
takes down only those. stop() also removes the shutdown hook start() registered,
which was accumulating one per instance and calling stop() again at exit.

Redis and InMemory built a cache wrapper on every memoize call. Both wrappers are
stateless and their value type is erased, so one instance now serves every A -
these are the per-request caching paths, and the allocations sat in front of
every read.

Also: the commented-out Lift imports in APIMethods400 and APIMethods600 are
restored to what Lift imported, a global rename having swept them up; the
scaladoc for classify, ?+ and ?- no longer documents a type parameter they lost;
and the ArrayBuffer alternative in validateRequiredFields, which Iterable already
covers, is gone.
…' own work

Two comments claimed things the code does not do. APIUtil said a wrong-typed
ResourceDoc body still surfaces because "getAllFields now throws for a body it
cannot describe" - that throw was tried, fired on legitimate scalar input and was
reverted, so the widening from scala.Product to Any has no replacement check and
the comment now says so. Redis carried two adjacent blocks, one describing the
cache as built per call and the next as built once; they are one block again.

The gRPC server's bus-ownership flags are volatile, like actualPort above them
and for the same reason: stop() also runs on the shutdown-hook thread, which
never synchronised with whoever ran start(). Ownership is also read after each
start() rather than before, since a disabled bus makes start() a no-op and "was
not running beforehand" alone claimed one this instance never started.

RedisDeserializeMissTest asserted `outcome.right.toOption should not be
Some("NONE")` directly after asserting the outcome is a Left, so the projection
was always None and the assertion could not fail - in the one suite whose purpose
is to keep a sentinel value from being cached as a hit. It asserts on the Either
now. The StarConnector scenario gained the initiate() guard its sibling already
had, and a try/finally whose body and finally were the same call is a plain call.

Both childValue overrides now say why they are public: TransmittableThreadLocal
declares the method public and 2.13, unlike 2.12, rejects narrowing it.
…unds

Two files still described work that had been reverted or refuted.
ProxyConnectorTest's scaladoc kept the account ConnectorProxy now contradicts -
that forwarding throws when the proxy library passes a null argument array. It
does not: `args: _*` compiles to Java varargs and Method.invoke reads null as no
arguments, which is why StarConnector broke on zipping args and this proxy never
did. SwaggerPathOrderAndArrayBodyTest credited jArrayBodyOf, a helper that was
tried, refuted by its own test and removed; the root-collection branch in
getAllFields is what actually replaced the leak.

ObpGrpcServer.start() is guarded against a second call on the same instance. It
would have recomputed the bus-ownership flags against buses it had already
started, reading them as somebody else's, and overwritten both `server` and
`shutdownHook` - leaking the first server with its port bound and orphaning its
hook with no reference left to remove it.

RequiredInfo.flatten matched ArrayBuffer immediately before Iterable with the
same body; ArrayBuffer is an Iterable, so only the second arm could ever run.
The comment beside it called this production method a test.

The first scenario in JSONFactory1_4_0_LightTest asserted the whole rendering of
a reflected field list, $outer included, while the three below it had been
rewritten away from exactly that - their comments naming $outer as the example
of what not to pin. It asserts on field names now, like its siblings.
…efects

The two frozen fixtures are Java-serialized blobs that exist to fail when a
frozen type drifts - which only works if a human can see what changed, and a
binary diff shows nothing. This branch had to regenerate both, because
collections written under 2.12 do not deserialize under 2.13, and it went in as
two unreadable blobs whose contents nobody could compare. The stated
justification, that no connector DTO changed, does not cover what they actually
store: reflection renderings, which this branch already showed differ between
2.12 and 2.13.

Extracting the serialized strings from both revisions settles it.
frozen_type_meta_data lost nothing and gained three entries -
APIUtil.JArrayBody, org.json4s.JArray and PostAccountTagJSON. RestConnector's
differences are the json4s alias rendering alone, org.json4s.JsonAST.JValue
becoming org.json4s.JValue, which a normalizeTypeName predating this branch
already absorbs. Benign, but it should not have taken a hex dump. Each blob now
has a checked-in text sibling, and FrozenMetaDataTextTest fails when the two
disagree - so the next regeneration lands in the diff as readable text.

release_notes.md records the migration. The artifact's class files are Java 25
now, where 2.12 emitted Java 8 whatever -release said, so anything loading them
on an older JVM fails at class load rather than at first call; the byte-buddy and
scalacache swaps are noted with it.

run_tests_parallel.sh armed its lock's EXIT trap at startup rather than where the
lock is taken, so an interrupted run deleted the lock a different run was holding
and let a third in beside it - with -am the protected section covers two
artifacts now, which lengthens the window.

ObpGrpcServer.start() rolls back on failure. Its guard keys off `server`, set
last, so a bind failure left the buses started and the guard open; the retry then
recorded them as somebody else's and stop() would leave them running.

Also: the sandbox permission lists no longer grant three cglib properties, cglib
having been removed from the build; the -Xsource:2.13 comment no longer describes
the sources as 2.12; and a blank line left by a removed import is gone.
…wn work

The frozen-fixture text mechanism was built wrong in three ways. Its documented
regeneration flow did not regenerate: the test only wrote a .txt when one was
absent, so after a real regeneration it failed on a 200KB diff with no way
forward. It wrote into the source tree at all, which is a test repairing the
thing it exists to check. And it rendered RestConnector's connector method names
as endpoints of a version called "methods" - in the one artifact a reviewer reads
to judge a regeneration. Rendering and writing move to FrozenMetaDataText, which
reads the blobs and needs no server; the test only compares.

-Xsource:2.13 is removed from both poms. scalac describes the flag as enabling
"warnings and features for a future version", and 2.13 is not a future version of
a 2.13 compiler - it did nothing after the flip, and last round's comment
justified keeping it with a behaviour the flag does not have.

The lock in run_tests_parallel.sh records its holder's PID and the waiter
reclaims a lock whose PID is gone, with a ten-minute ceiling. Moving the trap to
where the lock is taken stopped a run from deleting somebody else's lock, but
left the reverse: a run killed before the trap was armed orphaned the directory,
and the wait loop had neither timeout nor staleness check, so every later run on
that machine hung silently.

ObpGrpcServer's start rollback keeps the failure that caused it - a throwing
stop() is suppressed onto it rather than replacing it - and catches NonFatal
rather than Throwable.

The release note names the flip commit rather than a branch that will not exist
after merge.
The pid the lock records is written one statement after the mkdir that takes it,
so a run killed in between left a directory with no pid inside - and the waiter
only reclaimed a lock whose recorded pid was dead. Nothing removed that one, so
every later run waited out the full ten minutes and exited. A lock with no
recorded holder is now reclaimed too, after a grace period, since a live holder
is only momentarily in that state.

The stale branch also ran rm -rf and continued without sleeping or advancing the
timer, so a removal that could not take - a lock left in /tmp by another user -
became a busy loop that the new timeout could never end, because only the sleep
path advanced the counter. Every path through the loop now sleeps and advances
it, and a removal that does not take fails with the reason rather than spinning.

The EXIT trap is armed once at the top again, but checks the recorded pid before
removing anything. Armed unconditionally it deleted locks other runs held; armed
after the mkdir it missed signals in between; and disarming it after release left
an instant where an exit deleted a lock somebody else had just taken. Checking
ownership covers all three, and needs no disarm - once the directory is gone the
check cannot match.

Verified against the three cases: a dead pid is reclaimed in one poll, a lock
with no pid after the grace period, and a lock held by a live process is left
alone.
…dead

scalatest-maven-plugin runs with forkMode=once, so a shard is two JVMs: mvn, and
the test JVM it forks. Pekko's non-daemon threads keep that fork alive after the
tests finish, mvn exits anyway, and the fork is reparented with nothing owning
it. The timeout wrapper never sees this - it fires only when mvn itself overruns,
and on the ordinary path mvn returns 0.

They accumulate. Five were found alive six to ten hours after their runs, one of
them holding port 8080, where it answered a verification probe with a build eight
commits old and reported no error at all. A reaper now runs once, after every
shard has been waited on, matching on the plugin's own -Drun.mode=test together
with this checkout's path - a dev server started by hand carries no run.mode=test
and another checkout has another basedir. It is called from neither branch's
shard loop: the shards run in parallel and share the matcher, so reaping from
inside one would kill the JVMs the others are still using.

It did not fire on the run that followed, which left one straggler that then
exited by itself, so the orphan condition is not reproduced here - the reaper is
a safety net whose match scope is verified rather than a fix demonstrated to
trigger.

The lock's staleness test is ps -p rather than kill -0. kill -0 fails with EPERM
for a live process owned by another user as well as with ESRCH for one that is
gone - `kill -0 1` fails for root's launchd while `ps -p 1` succeeds - so on a
shared machine a running build of another user was reported as a dead holder.
The orphan reaper selected what to kill with `pgrep -f "Drun.mode=test"` and
`grep -q -- "$CHECKOUT_ROOT"`, both of which read their argument as a regular
expression - and a checkout path contains a literal dot, in .claude, which a
regex takes as any character. The selection ends in kill -9, so it should say
what it means. Verified against a real forked test JVM that the fixed-string
form still matches it.
…p lacks

The comment added with the previous fix said "-F on both". Only grep took -F:
pgrep has no fixed-string option, so its pattern stayed an extended regex whose
dots match any character - the thing that change was made to remove, still in
place two lines under a comment saying otherwise. `pgrep -f "sleep 3.0"` matching
a running `sleep 300` is the demonstration. The dots are escaped now, and the
comment says what each half actually does.
The reaper ran straight after the shards were waited on, which is before the
authoritative verdict reads the surefire XMLs and before the speed report reads
them again. This script already counts a report truncated by a JVM killed
mid-write as a broken suite - so a fork still flushing when the reaper reached it
would have failed a run whose tests all passed, manufacturing the exact failure
mode that audit exists to catch. Moved below both reads, where nothing left alive
can change the verdict.
…swallowing

.gitignore excludes obp-api/src/test/resources/** and re-includes fixtures one by
one; the blob predates that rule and is grandfathered in, but the text rendering
added beside it got no negation line and was never committed. git status stayed
clean throughout, so nothing said so.

The consequence is a test that passes only for whoever generated the file:
FrozenMetaDataTextTest asserts the text exists before comparing it, and on a
clean checkout - CI included - it does not. Confirmed by moving the file aside
and running the suite: "frozen_type_meta_data.txt is missing; run
code.util.FrozenMetaDataText to write it".

The negation follows the convention already in that file, whose neighbouring
comment records the same lesson about the dev certificate set: without an
explicit line, a regenerated fixture is silently untracked and the tests that
need it fail everywhere but the machine that made it.
…of it

type Coll[T] backs every collection test in SwaggerJSONFactory's type dispatch.
It was GenTraversableLike on 2.12 and is IterableOnce here - and 2.13's Option
implements IterableOnce where 2.12's did not, which javap on both libraries
confirms. So Coll[X] answers true for Option[X], and wherever a Coll case is
reached before the matching Option case, a scalar field is published as an array
of itself. That reaches clients: the swagger definitions are what they generate
code from.

Two cases reach it first. The String block tests Coll[String] before
Option[String] - the one scalar block written in that order - so every optional
string became an array of strings. And the generic List-or-Array fallback at the
end catches every Option the named cases did not: an Option of a case class, of a
JValue, published as an array of it.

The alias itself cannot go back to Iterable: that is what made scala-reflect's
FindMembers recurse until it overflowed, which is why IterableOnce was chosen.
So the fix is per case. String's Option case moves above its Coll case, matching
every other scalar block. The generic fallback excludes Option, which drops those
types onto the Option case already sitting below it - it unwraps and recurses,
which is what should have happened.

Deliberately not applied to the other Coll cases: they are safe by ordering, and
`isOneOfType[Coll[Date], Option[Coll[Date]]]` is a mixed test that a blanket
!isTypeOf[Option[_]] would break, taking Option[List[Date]] with it.

SwaggerOptionFieldTypeTest covers both directions - two scenarios that were red
before the change, and four that must stay green through it, Option[List[Date]]
among them.
…case

The previous commit moved the whole Option[String] case above Coll[String], but
these cases carry two independent clauses: a type test, and an isNestEnumeration
test. The enumeration clauses have an order among themselves that matters -
isNestEnumeration digs to the innermost type argument, so Option[List[Colour]]
satisfies isNestEnumeration[Option[_]] exactly as well as
isNestEnumeration[Option[List[_]]], and whichever is tested first wins. Only the
second is right for it.

Hoisting the Option[_] enumeration clause to the top of the block therefore
claimed every optional list of enumerations and published it as a string instead
of an array of strings. The full suite passed with that in place: nothing covered
Option[List[SomeEnumeration]], including the guards added with the fix, which use
String, Date and a case class.

Only the type test moves now. Four enumeration shapes are pinned - a bare
enumeration, a list of them, an optional list of them, an optional one - so the
next change to this block's order fails here rather than in a published document.
… nothing

translateEntity returns early for a JArray and builds the schema from its first
element. A bare Scala collection had no such branch: it fell through to the field
map, was answered with Map.empty, and the endpoint published "properties": {}.
Three endpoints return that shape - getSystemLevelEndpointTags,
getBankLevelEndpointTags, createUserWithAccountAccessById - across five API
versions each, so fifteen published response bodies described nothing.

2.12 reflected over the cons cell and leaked head and tl. That was wrong, but the
element's real schema came out under head, so the migration made these strictly
less informative than they had been.

The Map.empty is there to stop reflection following Nil's static EmptyUnzip,
which is (Nil, Nil) and leads back to Nil for ever - the comment beside it then
claimed "the value cases below already take the first one", which is true of a
JArray and not of a List. A collection is now answered the way a JArray already
was: an array whose items are the translated head, or an empty array when there
is no head. Neither path reflects over the collection, so the recursion stays
impossible. Map is excluded deliberately - an Iterable, but not a JSON array.

Found by diffing the published surface against a pre-migration baseline rather
than by reading the code, which had been read past this comment several times.
The swagger fixes change how 62 typed response bodies are described - the three
bare-List endpoints across five versions, plus 47 nested collection fields that
2.12 rendered as empty objects. The responses themselves are unchanged, so the
release note's "shapes are the same" claim stays true, but a reader diffing the
published documents would see 62 differences with nothing explaining them.
Verified against the 2.12 baseline: 0 breaking, 62 corrections, 0 regressions.
createOpenAPI31Json serves GET /obp/vX/resource-docs/API_VERSION/openapi and
reads typed_success_response_body structurally, walking it for type, properties
and items. No test file in the repository referenced OpenAPI31 at all, and this
branch changed that field for 62 (version, endpoint) pairs - handing it a
root-level array where it used to get an object, which is the one shape an
object-oriented conversion drops.

It handles them correctly. Checked against the live endpoint first: 2.9MB, 421
paths, the three corrected endpoints emit {"type":"array","items":{...}} with the
element's four real fields, and no array schema anywhere lacks items. The 67
endpoints with no 200 schema are DELETEs whose success body is null or a bare
true, unchanged by this branch.

That was a hand check. These five pin it: an object body, a root array, a nested
array field, the declared 3.1.0 version, and an absent typed body.

The path key is looked up rather than spelled - createOpenAPI31Json runs the url
through convertPathToOpenAPI, and asserting the result would test that transform
instead of the schema conversion.
The root-collection branch described its element with translateEntity, which
only knows how to reflect over an object's constructor arguments. Hand it a
scalar and it answers {"properties":{},"type":"object"} - the element's real
type is gone.

Twelve published request bodies are lists of enumeration values:
createAuthenticationTypeValidation and updateAuthenticationTypeValidation across
five API versions. 2.12 reflected over the cons cell, so `head` was a *field*,
and a field holding an EnumValue goes through the case that emits
{"type":"string","enum":[DirectLogin, GatewayLogin, ...]}. The members survived
by accident. Describing the list as an array of its head dropped them, which is
strictly less than 2.12 published.

Reproduced across four element kinds - enum, string, integer, boolean all came
back as an empty object - so this is a scalar vocabulary, not an enum special
case. elementSchema mirrors the cases the per-field loop already has and sits in
front of the translateEntity fallback, which still handles object elements.

Found by diffing typed_request_body against the pre-migration baseline. The
contract suite records that field but only ever diffs the response side, so
every schema change on this branch altered both surfaces while one went
unchecked - which is how the regression passed a clean contract run.
Brings in the nine commits upstream added after this branch was cut - chat
message constraints and email digest, the password-policy endpoint, signal
channel sanitizing, and the Sonar annotations - so the branch is tested as it
will merge rather than as it stands alone.

They were written on 2.12. Compiling and testing them under 2.13 is the point of
merging here rather than leaving it to the merge button: a long-lived branch
being green on its own says nothing about the merge, which is what the
pull_request build actually compiles.
@hongwei1 hongwei1 closed this Aug 17, 2026
@hongwei1 hongwei1 reopened this Aug 17, 2026
@sonarqubecloud

Copy link
Copy Markdown

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