Skip to content

Changelog

Changelog

All notable changes to this project are documented here.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

0.2.0 - 2026-10-14

Added

  • Gradle Plugin Portal publishing (#24). The Release workflow now also publishes com.chrisjenx.serialkompat to the Plugin Portal, and the plugin declares Configuration Cache and Isolated Projects support. The first Portal version needs Gradle's manual approval; until it is live, keep resolving the plugin from Maven Central.

0.1.0 - 2026-10-08

Security

  • Secret scanning with gitleaks. A Secret Scan CI workflow scans the full commit history on every push and PR, with --redact so a matched secret never reaches public logs. A .pre-commit-config.yaml runs the same check locally, and .gitignore now blocks common credential and signing-key files (*.gpg, *.pem, *.p12, *.jks, .env, …). A scan of the full existing history found no leaked secrets.

Fixed

  • The extractor no longer aborts on a type whose serializer can't load (#190). A NoClassDefFoundError, ExceptionInInitializerError, or other Error while walking one type used to fail the whole extraction. That type is now an OPAQUE coverage gap and the rest extract normally.
  • Value classes over a nullable value keep that nullability (#199). value class Id(val raw: String?) is now recorded as kotlin.String? (also inside List/Map), so narrowing it to String is a PROPERTY_TYPE_CHANGED BREAK instead of passing silently.
  • Shape changes around a generic type parameter are now caught (#192). List<T> → T or Map<String,T> → Map<Int,T> on a root-only generic is a PROPERTY_TYPE_CHANGED BREAK; it used to be skipped whenever either side contained a type parameter. A hole↔concrete flip is still not a finding.
  • ENUM_VALUE_ADDED coercibility counts every reader in the baseline, not just in-scope ones (#194). A required field in an out-of-scope type (for example one another module owns) now keeps the forward verdict at BREAK instead of WARN.
  • The sticky PR comment no longer duplicates on busy PRs, and its icon matches the job (#189). The comment lookup now paginates past 30 comments. ❌ now means the check failed (possibly with no BREAK, such as an empty baseline), ⚠️ covers BREAKs that don't fail the build under failOnBreaking = false, and a failed run without a report shows ❌ instead of "nothing configured".
  • A custom layout.buildDirectory is honored (#195). Setting it in the build script body used to split the extract and check tasks across two directories, so a stale build/serialkompat/current.snapshot could be diffed as the current schema (a false pass).
  • serialkompatRecord reads project.version lazily (#196). A version set in the build script body is now seen, and an unversioned project no longer fails configuration; only running serialkompatRecord fails without a version.
  • serialkompatCheck works on the root project of a single-module build (#203). The nested baseline build asked for ::serialkompatExtract, which Gradle rejects.
  • The baseline worktree checks out the resolved commit SHA, not the ref (#188). A ref that moved between resolution and checkout could cache the wrong schema under the old SHA.
  • The baseline snapshot cache is keyed by serialkompat version as well as commit SHA (#202). Upgrading serialkompat re-extracts the baseline instead of reusing one an older extractor produced.
  • Multi-module builds no longer run baseline extractions concurrently (#183). Under --parallel, each module raced the others on git worktree add and started its own nested Gradle daemon. The check tasks now take turns through a shared build service, while extraction and compilation stay parallel. The plugin is tested under --parallel, the configuration cache, and Gradle Isolated Projects.
  • The extractor now runs your @Serializable classes on your project's own runtime (#180). Previously the plugin's kotlinx-serialization and Kotlin stdlib came first on the classpath, so a project on a newer version had its serializers loaded against older runtimes. Your runtime classpath now comes first; the plugin's jars only fill gaps.
  • SUBTYPE_ADDED now credits a default deserializer on sealed bases, but only for tolerant readers (#181). Adding a subtype to a sealed base with an Unknown fallback was a false forward BREAK; it is now a WARN when the old reader has ignoreUnknownKeys. Without ignoreUnknownKeys the real decode throws, so it is now a BREAK (the #128 WARN was fail-open there). Both verdicts are backed by real round-trip oracles.
  • @EncodeDefault is now read from bytecode, so a field made optional can't fail open (#158, #176). Previously a field made optional with @EncodeDefault(NEVER) under encodeDefaults=true scored SAFE, yet old readers that still require it throw MissingFieldException. Now NEVER → BREAK, ALWAYS → SAFE, no annotation → follows encodeDefaults, and an unreadable mode (a hand-written serializer, or a snapshot recorded before this fix) → WARN under encodeDefaults=true, never SAFE. API: EncodeDefaultMode gains ABSENT, Element.encodeDefault == null now means "unknown", and Change.ElementOptionalityChanged gains newEncodeDefault.
  • Unresolved @Contextual elements are now an OPAQUE coverage gap instead of passing silently (#131). Their wire shape is chosen at runtime, so the gate can't see it; it used to report no change even when the real shape changed.
  • ENUM_VALUE_ADDED forward is now sound when the old reader uses coerceInputValues (#129). The old reader coerces an unknown value only into a field's default, so the verdict is WARN only when every baseline field reading that enum has a default. A required field, a List/Map use, or a top-level decode still throws, so those stay a BREAK.
  • List values containing whitespace no longer corrupt the snapshot file (#146). A jsonNames or enum value with a space, tab, or newline used to split apart or break the file on parse. parse(serialize(s)) == s now holds for every snapshot; whitespace-free output is byte-identical and older snapshots still parse. (Follow-up to #56.)
  • The gate fails closed on an empty baseline (#78). A baseline with zero contracts used to diff as "everything added → safe", hiding removals; serialkompatCheck now fails with a clear message. Set serialkompat { failOnEmptyBaseline } to false for first-time adoption with OPT_IN/OPT_OUT discovery, when the baseline ref has no @Serializable types yet.
  • Documentation reconciled with the shipped code (#87). The design doc's Gradle DSL and rule names now match the real SerialkompatExtension and Rules constants, and a phantom serialkompatDump task was removed. Version references were updated to Kotlin 2.4.0 and kotlinx-serialization 1.11.0.
  • More rules are now proven against the real library by round-trip oracles (#86). New coverage: numeric widening (Float→Double, Byte→Int), numeric narrowing (Long→Int, BREAK both ways), and non-null→nullable. All agree with the classifier.
  • Baselines survive crashes (#83). The snapshot cache now writes atomically, so a crash can't leave a half-written baseline that a later run trusts. A leftover git worktree from a killed run is cleaned up automatically instead of wedging every later run.
  • The CLI's --direction accepts the space-separated form (#85). --direction BACKWARD used to be ignored silently (falling back to FULL) and shifted the file arguments. Both --direction=VALUE and --direction VALUE now work, and a --direction with no value exits 2 with a usage error.
  • Escaping gaps closed in the snapshot file and JSON report (#84). The classDiscriminator and namingStrategy config values now round-trip exactly, avoiding spurious or missed config diffs. JsonReporter now escapes every control character, so a control character in a @SerialName no longer produces invalid JSON. Existing snapshots are byte-identical.
  • The GitHub composite action no longer interpolates untrusted inputs into scripts (#80). ${{ inputs.ref }}, inputs.task, inputs.gradle-args, and inputs.report-path were substituted into script bodies, which allowed shell/JS injection and could bypass the gate. All inputs now pass through env:, and the required pull-requests: write permission is documented.
  • One unresolvable type no longer aborts the whole extraction (#81). A single type that failed to load used to drop every type. Each type is now resolved independently, and a failure becomes an OPAQUE coverage gap (reported as a WARN) while the rest extract normally.
  • A custom JsonNamingStrategy is now recorded by its class name (#82). A strategy without its own toString() produced a different identity hash in each JVM, which caused a phantom CONFIG_NAMING_STRATEGY BREAK on every PR. Built-in strategies are unchanged.
  • Removing or renaming a field under a tolerant reader is now a WARN, not a false SAFE (#77). With ignoreUnknownKeys=true the old payload still decodes, but the removed field's data is silently lost. PROPERTY_REMOVED backward is now WARN for a tolerant reader and BREAK for a strict one, so a field rename (remove + add) now surfaces as one backward WARN instead of no findings at all.
  • The snapshot file now round-trips names that contain its delimiters. @SerialName, @JsonNames, and @JsonClassDiscriminator values may contain spaces, newlines, tabs, or backslashes, which used to corrupt the file. These names are now escaped; names without delimiters serialize byte-for-byte as before.
  • The CLI never crashes on bad input. A missing or malformed snapshot file, an unknown flag, or an invalid --direction now exits 2 with an error: message instead of a stack trace. Added --help/-h. Exit codes: 0 ok, 1 breaking, 2 usage/input error.
  • @JvmInline value classes are now recorded as their underlying wire type. A field typed UserId(val raw: Int) is recorded as kotlin.Int, so swapping a primitive for a wire-identical value class (or back) no longer produces a false BREAK. A value class that wraps a @Serializable type still checks that type.

Added

  • Seven more Json settings are captured and classified (#198): useArrayPolymorphism, allowStructuredMapKeys, allowSpecialFloatingPointValues, isLenient, decodeEnumsCaseInsensitive, allowTrailingComma, and allowComments. New rules CONFIG_ARRAY_POLYMORPHISM (BREAK both ways), CONFIG_STRUCTURED_MAP_KEYS (BREAK for the reader that lacks it), and CONFIG_SPECIAL_FLOATS (WARN for the reader that lacks it). The four reader-only flags join CONFIG_READER_STRICTNESS (WARN when tightened).
  • CLI --allow-empty-baseline (#187). It opts out of the new empty-baseline guard (see Changed) for first-time adoption.
  • serialkompatExtract is now build-cacheable and relocatable (#182). It restores FROM-CACHE on a clean build and from a checkout at another path. That includes the baseline git worktree, so with a shared or remote cache an already-built baseline commit needs no extraction JVM. The check tasks are intentionally never cached, because their verdict depends on the live baseline ref. See CI → "Build cache & configuration cache".
  • Round-trip oracles for the last rules without real-library proof (#119). DISCRIMINATOR_CHANGED, CONFIG_READER_STRICTNESS, CONFIG_COERCE_INPUT, DISCRIMINATOR_VALUE_CHANGED, CONFIG_CHANGED, and CONTRACT_REMOVED are now each backed by an oracle or a structural guard. The DISCRIMINATOR_VALUE_CHANGED oracle confirms its BREAK is a deliberate over-prediction.
  • Per-rule worked examples on the Rules docs page (#119). Each documented rule shows the change as a diff, the wire outcome with the real exception, and a link to the oracle test that proves the verdict. A checkRulesProof build gate fails CI if a proof link points at a missing test. Pilot rules: PROPERTY_ADDED, PROPERTY_REMOVED, ENUM_VALUE_ADDED.
  • Machine-readable report formats with a versioned JSON schema (#122). The JSON report carries schemaVersion ("1.0") so your tooling can depend on it. New formats: SARIF 2.1.0 (logical locations only, so no GitHub code-scanning upload) and GitHub Actions annotations (capped at 10 errors + 10 warnings, with a notice for the rest). Select formats in Gradle with serialkompat { reports { json { … }; sarif { … } } } (JSON on, SARIF off by default) or in the CLI with --format=console|json|sarif|github. The composite action also emits the capped annotations. See the "Report formats" docs page.
  • Gradual adoption with discovery modes and the new serialkompat-annotations module (#115). serialkompat { discovery } picks EXPLICIT (default: only types), OPT_OUT (every discovered type except @SerialkompatIgnore), or OPT_IN (only @SerialkompatChecked). The annotations are Kotlin Multiplatform, so you can mark types in KMP modules.
  • Generic @Serializable root types are now checked (#139). The fields of a generic envelope such as Page<T> are gated even when the type only appears as a root; its type parameters are treated as placeholders.
  • @Serializable discovery by scanning compiled class directories (#55, re-scoped). When no types are configured, the extractor finds roots in the classpath manifest plus a scan of compiled class files, without loading any classes. Generic classes are skipped as scan roots and logged by name, and unreadable class files become OPAQUE coverage gaps. This replaces the compiler-plugin producer originally planned in #55; KSP stays rejected (#22). Unblocks discovery modes (#115).
  • DISCRIMINATOR_COLLISION rule (#132). A subtype with a property whose JSON key equals the class discriminator can't be serialized at all, so the gate now flags it as a BREAK. It is skipped when classDiscriminatorMode = NONE.
  • baselineRef defaults to your default branch (#116). If you don't set it, the plugin tries origin/HEAD, then origin/main, origin/master, main, and master, and fails with a clear message if none exists.
  • Published-history checks in Gradle (#88, #121). serialkompatRecord records each release's schema into a committed history { dir } directory, and serialkompatCheckHistory checks the current schema against every recorded version. history { sinceVersion; depth; maxAge } bounds how far back that check reaches (#121).
  • Maven Central publishing (#24). serialkompat-core, serialkompat-extractor, serialkompat-gradle, and serialkompat-annotations publish under com.chrisjenx with the Gradle plugin id com.chrisjenx.serialkompat (moved off io.github.chrisjenx, which Sonatype refused). A Release workflow (manual dispatch from main with a version) checks the ref, publishing secrets, and version, then tests, publishes, tags, creates a GitHub release, and opens a PR bumping to the next -SNAPSHOT. A Snapshot workflow publishes -SNAPSHOTs on pushes to main; credentials live only in CI secrets. Gradle Plugin Portal publishing is still a follow-up, so resolve the plugin from Maven Central.
  • serialkompat { acceptedBreaks; renames } (#57). acceptedBreaks entries ("<serialName> <RULE> [DIRECTION]") are downgraded to acknowledged, so the gate logs them but doesn't fail. renames (old → new serial name) diffs a moved contract as a move instead of a remove + add.
  • Discovery from a classpath manifest. When no types are configured, the extractor reads type names from META-INF/serialkompat/serializable-types.txt (one per line; # comments and blank lines ignored). You can write it by hand or generate it; the class-dir scan (#55, above) is the built-in producer, not KSP (#22).
  • Persisted-data checks: TransitiveCompatibility.checkAgainstHistory checks the current schema against every published version, catching a break against an old version even when the latest looks fine. PublishedHistory is an append-only per-version snapshot store that refuses overwrites.
  • Dokka 2.0 API docs for core, extractor, and gradle, published to GitHub Pages by the Docs workflow on main.
  • serialkompat-cli: a standalone serialkompat diff <baseline> <current> for non-Gradle and cross-repo use, with --direction and --no-fail. It uses the same engine and report as the plugin. Exit codes: 0 ok, 1 breaking, 2 usage.
  • GitHub composite action (action.yml). It runs the gate, posts a sticky PR comment summarizing breaking, warning, and acknowledged findings, and fails the job on incompatible changes. The Gradle task itself stays CI-agnostic.
  • serialkompatCheckAgainst task: check against any ref with -Pserialkompat.ref=<ref>, falling back to the configured baselineRef.
  • Gradle plugin, end to end. serialkompatExtract dumps the current schema from your runtime classpath, and serialkompatCheck rebuilds the baseline from baselineRef and fails on unacknowledged breaks. The serialkompat { … } extension covers types, jsonInstance, baselineRef, direction, failOnBreaking, and include/exclude. Applying the plugin without types is a safe no-op.
  • CompatibilityEngine: the single entry point that scopes, diffs, classifies, and applies renames and accepted breaks to produce a Report. The Gradle task and the CLI share it.
  • Live git-ref baselines: GitRefBaseline rebuilds the baseline from the target ref's source in a throwaway worktree, so there is no committed baseline to go stale. Results are cached by commit SHA, and an unresolvable ref fails closed.
  • Scope and Coverage: include or exclude contracts by serial-name prefix (exclude wins). Excluded contracts are not checked, and reports don't list them yet.
  • Graceful degradation with ContractKind.OPAQUE. Anything the extractor can't analyse (an unknown SerialKind, an unresolved contextual or custom serializer, an extraction failure) becomes an explicit coverage gap, never an exception or a silent omission.
  • Rename and move tracking: the differ follows a declared renames entry as a ContractMoved and diffs the contents. A plain move is safe; a polymorphic move is a discriminator BREAK. A stale rename can't silently drop a contract.
  • Round-trip oracle test harness: encodes with one schema version, decodes with the other using real kotlinx-serialization, and asserts the classifier never misses a real break. The initial corpus covers field add/remove, nullability narrowing, type changes, numeric widening, and enum-value add.
  • Report, ConsoleReporter, JsonReporter, and AcceptedBreak: console and JSON rendering of findings, accepted breaks downgraded to acknowledged, and a shouldFail(floor) gate decision.
  • Json config changes are classified. Naming-strategy and discriminator changes are BREAK; tightening ignoreUnknownKeys or disabling encodeDefaults / coerceInputValues is WARN; loosening is safe.
  • JsonConfigReader: reads the wire-relevant settings from your real Json.configuration (naming strategy, discriminator, ignoreUnknownKeys, encodeDefaults, explicitNulls, coerceInputValues), so the gate uses your actual config instead of a copy.
  • Classifier, Finding, Rules, and CompatibilityProfile: apply the rule matrix to each change, per direction, using the reader and writer Json config. Only actionable (WARN/BREAK) findings are reported.
  • SnapshotExtractor and DescriptorSnapshotExtractor: walk the runtime SerialDescriptors to record serial names, optionality, nullability, @JsonNames, enum values, sealed subtypes, and SerializersModule-registered polymorphic subtypes.
  • SnapshotDiffer and the Change taxonomy: a deterministic structural diff of two snapshots. Reordering fields produces no change.
  • Snapshot model (Contract, Element, Subtype, SnapshotConfig, ContractKind, EncodeDefaultMode): the canonical, order-independent description of a JSON wire contract.
  • SnapshotFormat: a deterministic, sorted text format for snapshots that round-trips losslessly (parse(serialize(s)) == s).
  • Project scaffold: serialkompat-core, serialkompat-extractor, serialkompat-gradle modules.
  • Runtime descriptor reference extractor (reads element names and optionality from a SerialDescriptor).
  • SerialkompatPlugin registering serialkompatCheck and wiring it into the check lifecycle.
  • Tooling: Spotless (ktlint), Kover, binary-compatibility-validator, CI.
  • Design document (docs/design).

Changed

  • Sealed and polymorphic subtypes are identified by serial name and base, and reported as Base/sub (#201, #206). A @SerialName reused under two bases (OrderEvent/created, UserEvent/created) used to collapse into one contract and hide real breaks; each is now checked against its own base. Findings, reports, and Coverage.excluded show Base/sub. renames, acceptedBreaks, and include/exclude accept Base/sub (one base) or bare sub (every base), so existing bare entries keep working. Snapshot headers gain base=; older snapshots still parse and pair by bare name. A generic used with different type arguments (Page<A>/Page<B>) is still recorded as an OPAQUE coverage gap (#204).
  • A configured jsonInstance that can't be loaded now fails the extraction (#193). It used to print a warning and fall back to the default Json, silently checking every type under the wrong config and without the module's polymorphic registrations. Unset jsonInstance still uses the default. An open polymorphic base with no visible subtypes is now an OPAQUE coverage gap instead of an empty, falsely safe hierarchy.
  • The CLI fails closed on an empty baseline (#187). A baseline with 0 contracts against a non-empty current snapshot now prints an error: and exits 2, mirroring Gradle's failOnEmptyBaseline. --no-fail doesn't skip it; pass --allow-empty-baseline to opt out.
  • A contract that was OPAQUE in the baseline and is analysed now is one COVERAGE_GAP WARN, not a CONTRACT_REMOVED BREAK plus an add (#205). Analysed → OPAQUE is unchanged and stays loud.
  • Open polymorphic bases with a default deserializer are credited (#128). Adding a subtype to such a base is a forward WARN instead of a BREAK, because old readers decode the unknown subtype to the default. (#181 later also requires ignoreUnknownKeys on the old reader.)
  • Nullable fields under explicitNulls=false (#118). Adding a nullable field without a default is now backward SAFE, because the new reader decodes it as null. Removing one is forward WARN, because old readers silently see null. The default explicitNulls=true is unchanged (BREAK).
  • SnapshotFormat rebuilt on a document tree that drives both writing and parsing (#56). Output is byte-identical for every extractor-produced snapshot. Parsing is now stricter: a line that is invalid for its contract's kind is rejected instead of silently mis-read. A classDiscriminatorMode containing whitespace now round-trips; list values with whitespace were fixed separately (#146).