Skip to content

Move jev4k to Kotlin Multiplatform - #11

Merged
pambrose merged 16 commits into
masterfrom
kmp
Sep 28, 2026
Merged

pambrose merged 16 commits into
masterfrom
kmp

Conversation

@pambrose

@pambrose pambrose commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Warning

Don't merge until 0.2.0 is on Maven Central. This branch moves the install instructions to the new
com.pambrose.jev4k coordinates. Following the release checklist (docs/release-checklist.md), 0.2.0 is published
to Central from this branch once CI is green, and only then is the PR merged.

Summary

jev4k becomes a Kotlin Multiplatform library: the JVM plus Apple, Linux, Windows and Node.js (js and wasmJs), all from
common code. It also carries every fix from a full code review of the branch, docs/code-review-2026-09-27.md: all
85 issues are fixed, each with a note on how.

  • Breaking change: the Maven group is now com.pambrose.jev4k. Gradle builds depend on
    com.pambrose.jev4k:jev4k, and Maven builds on com.pambrose.jev4k:jev4k-jvm. 0.1.0 stays at com.pambrose:jev4k.
  • A few smaller API changes, which the release notes list up front, since every build has to change anyway:
    • JevApiException.headers has lowercased names on every platform.
    • models() returns a ModelList, a List<ModelInfo> that also carries the request id.
    • Every BlockingJev method declares InterruptedException, for Java.
    • jevApiException takes headers before retryAfter.
    • A plain http:// baseUrl on another host needs allowInsecureHttp = true.
  • New API: per-call options (JevCallOptions, withOptions) for the timeout, retry policy, headers and extra
    body fields; JevApi.blocking() for any JevApi; millisecond members for Java wherever a setting is a Duration.
  • Hardening:
    • Every request failure is a JevException, and cancellations stay cancellations.
    • JSON nested more than 128 levels is refused instead of overflowing the stack.
    • The configuration is validated when it's built, and its messages never echo a secret.
    • Redirects aren't followed.
    • A response declaring a body over 16 MiB is refused unread.
    • Mapping errors keep the raw body, status and headers.
  • One default Ktor engine per platform (CIO, Darwin, Curl, WinHttp, Js), with each engine's connection failures mapped
    to JevConnectionException.
  • Two fewer runtime dependencies: jev4k encodes its request body itself, so ktor-client-content-negotiation and
    ktor-serialization-kotlinx-json are gone. The request is byte for byte the same.
  • ABI validation for the JVM and klib surfaces (api/).
  • CI:
    • The build job runs the jvm, js, wasmJs and linuxX64 tests, and linuxArm64's under QEMU.
    • A native matrix runs macOS, the iOS, tvOS and watchOS simulators, and Windows.
    • The test job runs the JVM suite on JDK 17, 21 and 25.
    • A ci-ok job, required by branch protection with docs and GitGuardian, passes only when every job did.
    • The docs site is built in strict mode on every PR and deployed only when a release is published.

Test plan

  • make tests on a Mac: kotlinter, detekt, the ABI check, and the JVM, js, wasmJs, macOS, iOS-simulator and
    tvOS-simulator tests.
  • make docker-linux-tests: linuxX64 and linuxArm64.
  • cd website/jev4k && uv run zensical build --clean --strict: no issues.
  • make publish-local: every target's artifact, sources, javadoc, POM and module; jev4k-jvm has Java 17
    bytecode, the five documented dependencies and the expected manifest.
  • This PR's CI: ci-ok (build, JDK matrix, native Apple and Windows), docs and GitGuardian, all green.
  • make live-tests and make example against the real API, before publishing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5

pambrose and others added 4 commits September 27, 2026 14:31
jev4k now builds from common code for the JVM, Apple platforms, Linux,
Windows and Node.js (js and wasmJs), with a default Ktor engine per
platform. On the JVM, the public API, dependencies, bytecode level and
behavior are unchanged from 0.1.0.

- Breaking: the Maven group is now com.pambrose.jev4k. Gradle builds
  depend on com.pambrose.jev4k:jev4k, Maven builds on
  com.pambrose.jev4k:jev4k-jvm; 0.1.0 stays at com.pambrose:jev4k.
  The release notes, README and docs site call this out.
- ABI validation guards the JVM and klib surfaces (api/).
- The common tests run on every platform; LiveProbeTest adds live
  checks that spend no tokens.
- make docker-linux-tests runs the linuxX64 and linuxArm64 tests in
  Docker containers, and make platform-tests runs every platform's
  tests. The tvOS and watchOS simulator tests run where a simulator
  device exists.
- CI adds macOS and Windows jobs.
- Fix four empty admonitions and a broken anchor on the docs site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Replace defaultHttpClient(timeoutMillis) and DEFAULT_ENGINE_NAME with
  one defaultEngine per platform. CIO ignores its own requestTimeout
  whenever HttpTimeout is installed, which jev4k always does, so the
  argument had no effect; JevConfig.toString names the engine's class.
- Share the bare-IllegalStateException rule for Curl and WinHttp in
  nativeMain, and the test fixtures (PAYOUT_TICKET, triageJev,
  testDefaults, liveOptIn) in TestSupport.
- Read the simulator listing through a ValueSource that keeps only the
  platforms with a device, so a simulator run no longer discards the
  configuration cache; disable link tasks through KGP's binaries API.
- Makefile: HOST_TESTS, DOCKER_UP and LIVE_PROBE_TESTS replace repeated
  text; live-tests filters every platform to LiveProbeTest.
- CI: merge the native jobs into one matrix job, key the ~/.konan cache
  on the Kotlin version without restore-keys, and make docs.yml
  restore-only so Dokka can't save an incomplete toolchain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…view

defaultEngine = CIO was a stored top-level value in Platform.jvm.kt, so the
Platform_jvmKt class initializer loaded CIO. platformGetenv lives in the same
class and runs whenever a setting comes from the environment, so a consumer
who supplied an engine and excluded ktor-client-cio, as the README suggests,
got NoClassDefFoundError before any request. defaultEngine is now a getter in
its own Engine.jvm.kt, and CioExclusionTest builds a config in a class loader
that hides CIO (it failed before the change).

Add docs/code-review-2026-09-27.md: 85 verified issues with a checkbox summary
and a fix order. This change fixes item 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
Step 2 of docs/code-review-2026-09-27.md (items 13, 14, 15, 50, 58, 59, 61,
69 and 70):

- docs.yml builds the site and KDocs on every PR and master push with
  Zensical --strict, and deploys only when a release is published or on a
  manual run, so jev4k.com never shows a version that isn't on Central yet.
  The github-pages environment now also accepts release tags.
- ci.yml gains a ci-ok job that passes only when every other job succeeded;
  branch protection on master now requires ci-ok, docs and GitGuardian. The
  Apple row also runs the tvOS and watchOS simulator tests.
- Gradle's failOnNoDiscoveredTests guard is switched back on after the Kotest
  plugin turns it off, so a test binary that lost its specs fails the build.
- make build also compiles the JVM test sources (the doc examples and
  JavaInterop.java).
- The release checklist publishes to Central from the release PR's branch
  before merging, names GettingStarted.txt in its version steps, and drops
  its stale intro and line reference.
- The README's MockK fixture uses the enum's actual option key, TECHNICAL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
@codecov

codecov Bot commented Sep 28, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

pambrose and others added 12 commits September 27, 2026 20:41
…ntact

Step 3 of docs/code-review-2026-09-27.md (items 2, 3, 4, 5, 6, 8, 23, 53,
55 and 71):

- The response body is read as raw bytes and decoded leniently, so a
  malformed Content-Type or bytes that aren't UTF-8 no longer escape as Ktor
  exceptions. A leading byte-order mark is dropped.
- A body cut short of its Content-Length (Ktor's own check, on the JVM and
  native targets) is a connection error on every platform. On the JVM, an
  untrusted TLS certificate and a response CIO can't parse are too.
- A call on a closed client fails fast with IllegalStateException. A call
  cancelled because a sibling coroutine failed ends with its own
  CancellationException, and retriesOn follows Ktor's rule of retrying a
  cancellation only when it wraps a timeout.
- JSON nested more than 512 levels deep, in a state, a question entry or a
  response, is rejected instead of overflowing the stack.
- With a supplied engine, a timeout from that engine's own connect or socket
  timeout is named in the message instead of quoting config.timeout.
- The release notes, CHANGELOG, README and errors page describe the Linux and
  Windows bare-IllegalStateException rule.

New common tests run on every platform; ClientJvmTest drives a real CIO
engine against RawServer for the TLS, truncated-body and garbled-response
cases, which also pins Ktor's wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
Encoding a state nested 512 levels deep crashed the mingwX64 test binary in
CI: kotlinx.serialization recurses several frames per level, and Windows gives
the main thread a 1 MB stack. MAX_JSON_DEPTH is now 128 (serde_json's
default), a quarter of the stack that overflowed and still far deeper than any
real state or answer. The tests use the constant, so they check at and just
over the new limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
Step 4 of docs/code-review-2026-09-27.md (items 9, 10, 21, 28, 29 and 30):

- String settings are trimmed, and a value that is blank after trimming counts
  as unset, so an API key or URL read from a file with a trailing newline works.
- JevConfigBuilder.build() rejects what Ktor would otherwise refuse on every
  request with a raw exception quoting the value: an API key with a control
  character (which leaked the whole key), an invalid header name or value, and
  a base URL that doesn't parse. A base URL with credentials, a query or a
  fragment is rejected too. No message quotes the key, a header value, or a
  URL that could hold credentials.
- Plain http:// is accepted only for a loopback host unless the new
  JevConfigBuilder.allowInsecureHttp is set. The ABI dumps record the addition,
  and the release notes, README and Installation page say it is the one other
  change that can break code written for 0.1.0.
- The retry policy keeps its own copy of the status set, and a blank per-call
  model falls back to the configured default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
…izable errors

Step 5 of the code review (docs/code-review-2026-09-27.md), batched into one ABI update:

- JevCallOptions overrides a call's timeout, retry policy and headers and can add
  top-level body fields, through new JevApi members with default implementations
  and JevApi.withOptions (#27). Headers are now set on each request: Ktor's
  DefaultRequest merges its headers with a request's own, so a per-call header
  would have been sent twice.
- models() returns a ModelList, a List<ModelInfo> carrying the request id (#20).
- Every Duration setting has a millisecond twin for Java, RetryPolicy gains a
  with... method per setting, jevApiException takes headers before retryAfter and
  reads the hint from them, and jevResult has @jvmoverloads (#12).
- BlockingJev's methods declare InterruptedException (#42), and JevApi.blocking()
  wraps any JevApi (#44).
- JevApiException lowercases header names (#7) and, like JevRateLimitException,
  holds only Serializable state (#43).
- ValueJson loses its needless @PublishedApi (#46); the typed builders' argument
  order (#40) and the sealed-subtype policy (#41) are documented.

The changelog and release notes list the few source-level changes from 0.1.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
…s fully

Step 6 of the code review (docs/code-review-2026-09-27.md):

- enumChoice<E>(id) requires E to cover every declared option, a caller mistake
  reported as IllegalArgumentException instead of a response error (#11).
- A null answer counts as absent (#17); a non-finite number or a Score level
  outside the question's levels is a response error with its path (#18).
- Mapping errors carry the body as received, the status and the headers, through
  an internal ResponseInfo that send builds once (#19).
- A number or boolean state is rejected, and instructions must be non-blank text
  or a non-empty object or array (#31); a question entry holding NaN or an
  infinity is reported by validation (#32); jsonOf converts primitive arrays (#33).
- A JevValidationException inside a builder lambda is carried on the question
  instead of escaping a JevQuery's initializer, and the question's shape isn't
  checked, so no knock-on count is reported (#34).
- A JevQuery rebuilds its set when questions register after a read (#35);
  QuestionSet.ids reads the set's own copy (#36); question(id, q) copies its map
  or list and checks the answer's type (#37, #38).
- An undeclared option in a string-keyed Choice is returned as sent, as both
  official SDKs do; that policy is now documented and tested (#39).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
… body cap

Step 7 of the code review (docs/code-review-2026-09-27.md):

- Redirects are reported, not followed (#25). Ktor strips only Authorization on
  a cross-host redirect, and fetch re-sends the POST body; every default engine
  leaves redirects to Ktor, so followRedirects = false covers them all.
- ContentNegotiation uses SkipIfPresent, so a configured Accept is the only one
  sent (#24).
- A Retry-After HTTP-date is honored, measured from an internal clock and capped
  by maxRetryAfter, as the Python SDK does (#22).
- A response declaring a body over 16 MiB is refused before Ktor reads it, from a
  receive-pipeline phase ahead of SaveBody: a 2xx as a response error, any other
  status as its usual exception with no body (#26). A chunked body stays bounded
  only by the timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
…ter mocks

Step 8 of the code review (docs/code-review-2026-09-27.md):

- jevResult(String) checks the body with the client's own parser, now
  ResponseInfo.parseObject(), so non-JSON text is a JevResponseValidationException (#48).
- A handle from another QuestionSet with a matching id gets a message that says
  so; the docs show answers { jevResult(body, secondArg()) } for questions built
  per call (#47).
- Every test task sets JEV4K_ENV_PROBE (SIMCTL_CHILD_-prefixed on simulators, and
  passed to the Docker Linux containers), and PlatformEnvTest checks that
  platformGetenv reads it on every platform (#51).
- mapModels's rejection branches (#52) and a supplied engine's timeouts (#54) are
  tested; the real-CIO timeout test warms CIO up first (#56); BlockingJevTest
  checks the forwarded questions (#83).
- make live-tests fails at once without TYPESAFE_API_KEY (#57).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
Step 9 of the code review (docs/code-review-2026-09-27.md):

- CI's build job sets up QEMU and runs make docker-linux-tests, the only place
  linuxArm64's tests run; the release checklist gives it its own checkbox (#60).
- check no longer processes, compiles or links test binaries the host can't run:
  iosX64's off an Intel Mac, mingwX64's off Windows (#62).
- javac compiles with --release 17, matching kotlinc's -Xjdk-release (#63).
- -XX:+EnableDynamicAgentLoading is passed on every JDK; 11 and 17 accept it,
  and only the warning is new in 21 (#64).
- Corrected comments: coverage after a JVM test failure (#65), master runs under
  the concurrency group (#66), the Kover skip example (#67), and why dependabot
  leaves out website/uv.lock (#68).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
… reach

Step 10 of the code review (docs/code-review-2026-09-27.md):

- jev4k never logs, but Ktor uses SLF4J, which warns on stderr without a binding;
  the docs say so and suggest slf4j-nop (#72).
- RetryPolicy's defaults match the JS SDK; Python shares the retries but doesn't
  cap hints and adds a 30 s budget per call, which jev4k lacks (#49).
- The js and wasmJs artifacts are built and tested for Node.js but would load in a
  browser, which a browser app should avoid by calling its own backend (#74).
- Java can see the @PublishedApi helpers behind choice<E>(), but they aren't
  supported API (#45).
- .env reaches only the JVM tasks (#73); JavaInterop.java's comment lists what it
  pins (#82); CLAUDE.md says which SDK each type name follows (#84); two README
  formatting fixes (#85).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
Step 11 of the code review (docs/code-review-2026-09-27.md), which completes it:

- Line search, categorize and countFruits handle empty and oversized inputs, and
  the Search page gains the 255-option caveat (#16).
- The model router rounds an uncertain estimate up to at least LARGE instead of
  capping it there (#75).
- The fan-out examples bound their concurrency (#76), and ranking reads stored
  results, so both roles are ranked from one assessment (#77).
- The due-date month and the invoice total can answer none (#78).
- Guardrail policy is pure code over one assessment, applied under both policies
  (#80), and an uncertain self-harm signal goes to a person rather than being
  escalated to a block, unlike TypeSafe's cookbook (#79).
- The extraction checks carry explicit criteria, an empty field gets only an
  absence check, and an empty extraction escalates instead of throwing (#81).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
…l cases

A /simplify pass over the code-review fixes (8c89c87..HEAD):

- One apiError helper builds a failed response's exception for the normal and
  the oversized-body paths, and ResponseInfo.fail is the single way a response
  error is thrown, so parseObject reads as three straight guards.
- One sendProblem check (nesting, NaN or infinity) covers the state, question
  entries and extraBody, which closes a gap: a NaN in extraBody passed build().
  The per-call model reuses the config's trimming rule.
- QuestionProvider.provideDelegate catches a question that fails to build, in
  one place instead of three builders; that also covers an enum option whose
  JevOption.entry fails.
- LiveSmokeTest is gated on JEV4K_LIVE alone, so a live run without a key fails
  instead of skipping, replacing the Makefile guard. PlatformEnvTest checks PATH,
  replacing a probe variable wired through every test task type.
- One build.gradle.kts block skips every test binary the host can't run, the
  device-less tvOS/watchOS simulators included; CI runs only linuxArm64 in
  Docker, via LINUX_TEST_TARGETS, since the build job already ran linuxX64.
- Test helpers: localClient in ClientJvmTest, a testDefaults default, and a
  one-line request-head read in RawServer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
…escriptions

- jev4k encodes its one request body itself (extraBody merged in), so the
  ContentNegotiation plugin, its Accept-merge setting and two runtime
  dependencies are gone: jev4k-jvm's POM lists five, down from seven. A new test
  pins the request bytes the plugin produced, and they are unchanged. The JS
  lockfiles lose @js-joda/core, which one of the removed modules pulled in.
- CHANGELOG.md and RELEASE_NOTES.md date 0.2.0 on 2026-09-28.
- CLAUDE.md no longer says the JVM ABI dump still matches 0.1.0's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQjZxyKov2izXjyWbYdCc5
@pambrose
pambrose marked this pull request as ready for review September 28, 2026 07:26
@pambrose
pambrose merged commit 57a1dca into master Sep 28, 2026
12 checks passed
@pambrose
pambrose deleted the kmp branch September 28, 2026 07:26
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