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
Releaseworkflow now also publishescom.chrisjenx.serialkompatto 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 ScanCI workflow scans the full commit history on every push and PR, with--redactso a matched secret never reaches public logs. A.pre-commit-config.yamlruns the same check locally, and.gitignorenow 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 otherErrorwhile 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 askotlin.String?(also insideList/Map), so narrowing it toStringis aPROPERTY_TYPE_CHANGEDBREAK instead of passing silently. - Shape changes around a generic type parameter are now caught (#192).
List<T>→TorMap<String,T>→Map<Int,T>on a root-only generic is aPROPERTY_TYPE_CHANGEDBREAK; it used to be skipped whenever either side contained a type parameter. A hole↔concrete flip is still not a finding. ENUM_VALUE_ADDEDcoercibility 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.buildDirectoryis honored (#195). Setting it in the build script body used to split the extract and check tasks across two directories, so a stalebuild/serialkompat/current.snapshotcould be diffed as the current schema (a false pass). serialkompatRecordreadsproject.versionlazily (#196). Aversionset in the build script body is now seen, and an unversioned project no longer fails configuration; only runningserialkompatRecordfails without a version.serialkompatCheckworks 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 ongit worktree addand 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
@Serializableclasses 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_ADDEDnow credits a default deserializer on sealed bases, but only for tolerant readers (#181). Adding a subtype to a sealed base with anUnknownfallback was a false forward BREAK; it is now a WARN when the old reader hasignoreUnknownKeys. WithoutignoreUnknownKeysthe 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.@EncodeDefaultis now read from bytecode, so a field made optional can't fail open (#158, #176). Previously a field made optional with@EncodeDefault(NEVER)underencodeDefaults=truescored SAFE, yet old readers that still require it throwMissingFieldException. NowNEVER→ BREAK,ALWAYS→ SAFE, no annotation → followsencodeDefaults, and an unreadable mode (a hand-written serializer, or a snapshot recorded before this fix) → WARN underencodeDefaults=true, never SAFE. API:EncodeDefaultModegainsABSENT,Element.encodeDefault == nullnow means "unknown", andChange.ElementOptionalityChangedgainsnewEncodeDefault.- Unresolved
@Contextualelements 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_ADDEDforward is now sound when the old reader usescoerceInputValues(#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, aList/Mapuse, or a top-level decode still throws, so those stay a BREAK.- List values containing whitespace no longer corrupt the snapshot file (#146). A
jsonNamesor enum value with a space, tab, or newline used to split apart or break the file on parse.parse(serialize(s)) == snow 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;
serialkompatChecknow fails with a clear message. Setserialkompat { failOnEmptyBaseline }tofalsefor first-time adoption withOPT_IN/OPT_OUTdiscovery, when the baseline ref has no@Serializabletypes yet. - Documentation reconciled with the shipped code (#87). The design doc's Gradle DSL and rule names now match the real
SerialkompatExtensionandRulesconstants, and a phantomserialkompatDumptask 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
--directionaccepts the space-separated form (#85).--direction BACKWARDused to be ignored silently (falling back toFULL) and shifted the file arguments. Both--direction=VALUEand--direction VALUEnow work, and a--directionwith no value exits 2 with a usage error. - Escaping gaps closed in the snapshot file and JSON report (#84). The
classDiscriminatorandnamingStrategyconfig values now round-trip exactly, avoiding spurious or missed config diffs.JsonReporternow escapes every control character, so a control character in a@SerialNameno 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, andinputs.report-pathwere substituted into script bodies, which allowed shell/JS injection and could bypass the gate. All inputs now pass throughenv:, and the requiredpull-requests: writepermission 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
JsonNamingStrategyis now recorded by its class name (#82). A strategy without its owntoString()produced a different identity hash in each JVM, which caused a phantomCONFIG_NAMING_STRATEGYBREAK 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). WithignoreUnknownKeys=truethe old payload still decodes, but the removed field's data is silently lost.PROPERTY_REMOVEDbackward 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@JsonClassDiscriminatorvalues 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
--directionnow exits 2 with anerror: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 typedUserId(val raw: Int)is recorded askotlin.Int, so swapping a primitive for a wire-identical value class (or back) no longer produces a falseBREAK. A value class that wraps a@Serializabletype still checks that type.
Added
- Seven more
Jsonsettings are captured and classified (#198):useArrayPolymorphism,allowStructuredMapKeys,allowSpecialFloatingPointValues,isLenient,decodeEnumsCaseInsensitive,allowTrailingComma, andallowComments. New rulesCONFIG_ARRAY_POLYMORPHISM(BREAK both ways),CONFIG_STRUCTURED_MAP_KEYS(BREAK for the reader that lacks it), andCONFIG_SPECIAL_FLOATS(WARN for the reader that lacks it). The four reader-only flags joinCONFIG_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. serialkompatExtractis now build-cacheable and relocatable (#182). It restoresFROM-CACHEon 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, andCONTRACT_REMOVEDare now each backed by an oracle or a structural guard. TheDISCRIMINATOR_VALUE_CHANGEDoracle 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
checkRulesProofbuild 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 withserialkompat { 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-annotationsmodule (#115).serialkompat { discovery }picksEXPLICIT(default: onlytypes),OPT_OUT(every discovered type except@SerialkompatIgnore), orOPT_IN(only@SerialkompatChecked). The annotations are Kotlin Multiplatform, so you can mark types in KMP modules. - Generic
@Serializableroot types are now checked (#139). The fields of a generic envelope such asPage<T>are gated even when the type only appears as a root; its type parameters are treated as placeholders. @Serializablediscovery by scanning compiled class directories (#55, re-scoped). When notypesare 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_COLLISIONrule (#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 whenclassDiscriminatorMode = NONE.baselineRefdefaults to your default branch (#116). If you don't set it, the plugin triesorigin/HEAD, thenorigin/main,origin/master,main, andmaster, and fails with a clear message if none exists.- Published-history checks in Gradle (#88, #121).
serialkompatRecordrecords each release's schema into a committedhistory { dir }directory, andserialkompatCheckHistorychecks 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, andserialkompat-annotationspublish undercom.chrisjenxwith the Gradle plugin idcom.chrisjenx.serialkompat(moved offio.github.chrisjenx, which Sonatype refused). AReleaseworkflow (manual dispatch frommainwith 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. ASnapshotworkflow publishes-SNAPSHOTs on pushes tomain; 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).acceptedBreaksentries ("<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
typesare configured, the extractor reads type names fromMETA-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.checkAgainstHistorychecks the current schema against every published version, catching a break against an old version even when the latest looks fine.PublishedHistoryis 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
Docsworkflow onmain. serialkompat-cli: a standaloneserialkompat diff <baseline> <current>for non-Gradle and cross-repo use, with--directionand--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. serialkompatCheckAgainsttask: check against any ref with-Pserialkompat.ref=<ref>, falling back to the configuredbaselineRef.- Gradle plugin, end to end.
serialkompatExtractdumps the current schema from your runtime classpath, andserialkompatCheckrebuilds the baseline frombaselineRefand fails on unacknowledged breaks. Theserialkompat { … }extension coverstypes,jsonInstance,baselineRef,direction,failOnBreaking, andinclude/exclude. Applying the plugin withouttypesis a safe no-op. CompatibilityEngine: the single entry point that scopes, diffs, classifies, and applies renames and accepted breaks to produce aReport. The Gradle task and the CLI share it.- Live git-ref baselines:
GitRefBaselinerebuilds 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. ScopeandCoverage: 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 unknownSerialKind, 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
renamesentry as aContractMovedand 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, andAcceptedBreak: console and JSON rendering of findings, accepted breaks downgraded to acknowledged, and ashouldFail(floor)gate decision.Jsonconfig changes are classified. Naming-strategy and discriminator changes are BREAK; tighteningignoreUnknownKeysor disablingencodeDefaults/coerceInputValuesis WARN; loosening is safe.JsonConfigReader: reads the wire-relevant settings from your realJson.configuration(naming strategy, discriminator,ignoreUnknownKeys,encodeDefaults,explicitNulls,coerceInputValues), so the gate uses your actual config instead of a copy.Classifier,Finding,Rules, andCompatibilityProfile: apply the rule matrix to each change, per direction, using the reader and writerJsonconfig. Only actionable (WARN/BREAK) findings are reported.SnapshotExtractorandDescriptorSnapshotExtractor: walk the runtimeSerialDescriptors to record serial names, optionality, nullability,@JsonNames, enum values, sealed subtypes, andSerializersModule-registered polymorphic subtypes.SnapshotDifferand theChangetaxonomy: a deterministic structural diff of two snapshots. Reordering fields produces no change.Snapshotmodel (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-gradlemodules. - Runtime descriptor reference extractor (reads element names and optionality from a
SerialDescriptor). SerialkompatPluginregisteringserialkompatCheckand wiring it into thechecklifecycle.- 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@SerialNamereused 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, andCoverage.excludedshowBase/sub.renames,acceptedBreaks, andinclude/excludeacceptBase/sub(one base) or baresub(every base), so existing bare entries keep working. Snapshot headers gainbase=; 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
jsonInstancethat can't be loaded now fails the extraction (#193). It used to print a warning and fall back to the defaultJson, silently checking every type under the wrong config and without the module's polymorphic registrations. UnsetjsonInstancestill 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 exits2, mirroring Gradle'sfailOnEmptyBaseline.--no-faildoesn't skip it; pass--allow-empty-baselineto opt out. - A contract that was OPAQUE in the baseline and is analysed now is one
COVERAGE_GAPWARN, not aCONTRACT_REMOVEDBREAK 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
ignoreUnknownKeyson 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 asnull. Removing one is forward WARN, because old readers silently seenull. The defaultexplicitNulls=trueis unchanged (BREAK). SnapshotFormatrebuilt 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. AclassDiscriminatorModecontaining whitespace now round-trips; list values with whitespace were fixed separately (#146).