Releases: rcapraro/kdiff
Release list
kdiff 2.1.0
Added
PatchResult.prefixedWith(property),PatchResult.prefixedWith(segment)and
PatchFailure.prefixedWith(segment), which lift failures onto a prefix the way
Change.prefixedWithlifts changes. A hand-writtenPatchercalls the first on each helper's result
to report full paths, as the library's own patchers now do. It uses the second for an element of a
collection it rebuilds itself. Both return the same instance when nothing failed, so a clean
application allocates nothing for them.
Changed
- The tutorial derives its comparison, and applies a diff. It used to describe its model with seven
differ { }values, which is the route the rest of the documentation tells you not to start from.
It now derives one withautoDiffer { }and three exceptions.Money, which deriving refuses, is
where the refusal and its two exits are taught. A new section sends the handler's diff to a read
model, including one that missed an update. No published API changes. patching.mdsays what applying does to a source that has moved on. A change'sbeforeis
never read, so values are overwritten and a removal of something already gone is a no-op. Only a
change reaching beneath something absent is reported. This was always the behaviour, and now it is
documented, with a runtime test per case.
Fixed
-
A patch failure names the full path of the change it declines, however deep. Applying
addresses[id=A3].cityto an instance holding no addressA3used to report
city: no element with this key to patch. That names neither the list nor the element, and it is
ambiguous in any model wherecityoccurs twice. It now reports
addresses[id=A3].city: no element with this key to patch, on the generated, derived andtoPatcher
routes alike.PatchFailure.changecarries that full path too.Recompile to get it everywhere. The runtime restores element keys and indexes at once, but the
property segment is restored by generated code, so a differ generated by an earlier processor reports
[id=A3].cityuntil it is regenerated. Nothing stops compiling either way.A hand-written
Patcherchanges in one of two ways. If it composespatch*helpers, call
prefixedWith("<property>")on each helper's result, or its property segments stay missing. If it
worked around the old paths by prefixing the failures ofpatchKeyedList,patchPositionalListor
patchMapwith the element's key or index itself, remove that prefixing, or the segment appears
twice ([id=A3][id=A3].city). The helpers now return those failures already lifted onto the element.Restoring the property segment costs a clean
applyone check per property. Nothing is allocated,
but applying one change to an 18-property type measured 4–7% slower inkdiff-benchmarks.
Coordinates
implementation("io.github.rcapraro:kdiff-annotations:2.1.0")
implementation("io.github.rcapraro:kdiff-runtime:2.1.0")
ksp("io.github.rcapraro:kdiff-processor:2.1.0")Deriving a differ from a type's shape is a separate artefact, declared only by a consumer
who uses it — it is the one part of kdiff that brings kotlin-reflect:
implementation("io.github.rcapraro:kdiff-reflect:2.1.0")kdiff 2.0.0
Added
-
A derived differ reads a property through its Java getter, and compares 42–62% faster.
kotlin-reflect's own read goes through a reflective caller and costs about 1.9x a property reference
the compiler synthesised — which, measured, is most of the difference between a derived differ and a
hand-written one. The accessor is now resolved once, when the differ is derived.The gain depends on the model. Where reads are the whole of the work — a flat type of value
properties — 71% of the excess goes, and throughput rises 62%. On a model with nested types and
collections it is 42–44%. On a keyed list of a hundred elements, where allocation dominates, there is
no measurable change. Deriving itself costs 17% more, resolving those accessors once.Nothing is compared differently, on any route. A property whose type is a
value classkeeps the
slower read, because a JVM getter erases such a type and would yield the wrapped value rather than the
wrapper; recognising avalue classwithout being told to is unaffected. -
Deriving a differ now yields a patcher too, and a hand-written declaration can gain one.
autoDiffer<Order>()returns a value that both compares two instances and applies changes to one —
the shape@Diffablealready generates, where one declaration and one import reach both. A caller
who wroteval d: Differ<Order> = autoDiffer()is unaffected; the capability is there for whoever
reaches for it.DiffPlan.toPatcher(type)does the same for a declaration you wrote yourself: pair adiffPlan { }
with the type it describes and you have aPatchera builder could never give you, because a
declaration states how to read a type and never how to rebuild it. That closes what the
documentation presented as permanent — a@DiffWithobject that only compares no longer has to leave
its property unapplicable. → hand-written.mdRebuilding goes through the type's
copy, passing only the properties that carry changes. A
property you excluded, and one deriving could not name, keep their values without ever being read
— so this sets no accessibility flag, and a defaulted parameter is never silently reset. It is the
mechanism generated code already uses, which is what makes the two routes agree by construction
rather than by test;DerivedPatchParitySpecholds one model to one result and one failure list
through both.Applying stays partial, exactly as on the annotated route: a property delegated to a differ that
cannot rebuild its type has its changes reported and the rest of the instance still applies. -
PatchFailure.Reason.TypeNotRebuildablefor a type whosecopycannot be reached — aprivate
constructor under@ConsistentCopyVisibility. A fact about a type rather than a property, so not
UnpatchableProperty. The comparison is unaffected and still reports what differs; only putting the
result back is unavailable. Branch on the reasons you act on and give the rest anelse, as ever. -
A described subtype now names the differ it dispatches to.
PlannedSubtype.differ, alongside the
typeit already carried, the wayPlannedComparison.Nestedalready names a delegating property's
differ. A reader of a description could see that a plan dispatched and not to what.
Changed — description
-
PlannedPropertystates a property's name and how to read it, in place of the property reference.
A name is what a reported path carries and what a change is matched back to a property by, so it is
what a description of a comparison is an account of; how a value is reached is a decision about
running one. Separating the two is what lets deriving read more cheaply without any new published API.Declarations are unchanged.
field(Order::reference)still takes a property reference, on every
route. What moves isplan.properties[i].property, which becomes.name— so code matching a
description entry against a property reference stops compiling rather than starting to return
nothing. No released version offers that surface.
Changed
-
BREAKING —
autoDiffer<T>()refuses a type it cannot describe, whichever positionTis in.
The test deciding whether a type can be described property by property was applied to a property's
type and to a sealed subclass, and never to the typeautoDifferwas asked about. So the same
declaration derived at the root and was refused one level down, and for anabstractoropenbase
the root won:autoDiffer<Shape>().diff(Circle("c", "1"), Circle("c", "2")) // was []
Shapeis not sealed, so nothing dispatched toCircleand the propertyCircleadds went
uncompared — two instances that differ, reported equivalent. That call now raises
UndescribableShapeException.What you can derive for is a data class or a sealed type. An
abstract,openor ordinary class
is refused: make it a data class, make itsealedso deriving dispatches over its subclasses, or
describe it withdiffer { }. An enum, avalue classand anobjectare refused at the root for
the opposite reason — kdiff compares those whole, where they are held — so derive for the type that
holds one; as a property each is compared exactly as before. Declaring a type a value yourself with
asValue<T>()is unaffected, includingasValue<Node>()on a self-referential model, which is how
deriving is stopped from descending into one. → errors.md -
The documentation now says which route to choose, and recommends deriving.
hand-written.md
opens with a table: derive by default, annotate a comparison you have measured to be hot, and
write a declaration by hand only to splice an existing differ into a model or to describe a shape
deriving refuses. Deriving was presented as the answer to a model you would not annotate — a
fallback — which the measurements contradict.What it costs is quoted as ratios with the command that reproduces them, re-measured after the
accessor change above: a derived differ costs 1.7–2.6x a generated one, worst where nothing changed
and best six levels deep, and 1.2–1.5x a hand-written one. The band the page carried predates that
change and overstated it.api-stability.md§8 also records two decided non-features: deriving a
tracking scope, which an empty scope already does, and deriving properties visible only inside their
own declaration. → hand-written.md
Added
-
A fourth artefact,
io.github.rcapraro:kdiff-reflect, derives a differ from a type's shape.
autoDiffer<Person>()compares the properties the primary constructor states, each the way its own
type calls for — the same six comparisons@Diffableresolves, reported at the same paths — so a
domain you will not annotate needs no line per property, and a property added later is compared
with no edit. A Kotlinvalue classis recognised without being declared one.The block names only the exceptions, and each is the counterpart of one annotation:
exceptof
@DiffIgnore,asValueof@DiffAsValuein its property and its class form,nestedof
@DiffWith— which is also how a generated differ is spliced into a derived one — andkeyedList
of@DiffKey. There is deliberately nofield: a declaration naming both what to compare and what
to exclude would need a precedence rule, and such a rule decides silently which of them widens.Two things to know before adopting it. It reads shape, never kdiff annotations, so deriving a
differ for an annotated type compares what that type's annotations excluded — use the generated
differ for such a type. And it is the one part of kdiff that needskotlin-reflect: nothing
depends onkdiff-reflect, so a consumer that does not declare the coordinate carries no
reflection library, andkdiff-annotations,kdiff-runtimeand the generated code are unchanged
in that respect. Deriving applies a diff back as well as reporting one — see the entry above — and
never breaks in to do it: rebuilding goes through the type'scopyand passes only the properties
that carry changes. → hand-written.mdIt compares the constructor properties you could also name as exceptions — the
publicand
internalones — because a property you cannot write asType::propertyis one you could never
exclude, compare as one value, or delegate. Aprivateorprotectedone is skipped, and a type
stating nothing nameable is refused where the differ is derived rather than quietly comparing
nothing. Reading aninternalproperty sets no accessibility flag; that guarantee is exact for a
type your own module declares, anddocs/hand-written.mdstates the limit for a type you got from a
dependency. -
DiffPlan<T>describes what a differ compares.diffPlan { }builds the same declaration
differ { }does and returns it as a value — each property paired with the way it is compared, and
the subtypes it dispatches on — andtoDiffer()compiles it back.differ { }is now exactly
those two steps, so a plan and the differ built from it can never describe different comparisons.
A plan covers one type: a delegating property names its differ without describing it.Nothing existing moves, and no comparison costs more:
toDiffer()assembles the same closures the
builder assembled before, so nothing dispatches on the description while a comparison runs.
Coordinates
implementation("io.github.rcapraro:kdiff-annotations:2.0.0")
implementation("io.github.rcapraro:kdiff-runtime:2.0.0")
ksp("io.github.rcapraro:kdiff-processor:2.0.0")Deriving a differ from a type's shape is a separate artefact, declared only by a consumer
who uses it — it is the o...
kdiff 1.1.0
Added
-
A diff tree can be walked with each node's path.
DiffNode.walk()yields every node of a
subtree paired with theFieldPaththat locates it, so a consumer descending a tree generically —
an audit writer, a renderer that indents by structure — no longer accumulates segments itself. The
node you start from comes first, paired with the empty path, sodiff.tree().walk()yields
absolute paths and a change reported at the root needs no special case; walking a node further down
yields paths relative to it.Nodes come out in the order
allChanges()reports their changes — a node before its descendants,
siblings in the orderchildrenholds them — so walking and collecting can be used
interchangeably. That is the tree's order, which groups a subtree together, and not in general the
order of the flat list. The sequence is lazy: a caller that finds the subtree it wanted stops
without visiting the rest.Nothing existing moves.
DiffNodekeeps its constructor, its three properties,isEmptyand
allChanges(), socopyandcomponentNare unaffected — which is why the path is supplied by
the walk rather than carried on the node. → diffing.md
Coordinates
implementation("io.github.rcapraro:kdiff-annotations:1.1.0")
implementation("io.github.rcapraro:kdiff-runtime:1.1.0")
ksp("io.github.rcapraro:kdiff-processor:1.1.0")kdiff 1.0.0
The version where the promises stop being provisional. docs/api-stability.md was written before the
tag on purpose; this is the tag it was written for, and from here the recorded public API of the three
published modules, the closed Change vocabulary and the three declared exception types only change in
a major version.
Two things had to happen first. One is a retraction: PatchFailure.Reason was declared closed in
0.7.0 and is not — a minor version may declare a further reason, and freezing the set for the length
of 1.x would have made the next supported shape choose between a major version and a reason that
misdescribes itself. Better to loosen it before the tag than to discover it after.
The other is that the promises are now checked by the build rather than by a maintainer remembering.
A consumer build resolving only the three published coordinates compiles and runs inside
./gradlew check, the regeneration claim has a test that spans two builds, and every message
docs/errors.md quotes is compared to the source that emits it.
No behaviour of the library changes in this release. If you are on 0.7.0 and do not match
PatchFailure.Reason exhaustively without an else, the upgrade is the version number.
Added
-
The release is checked, not inspected. A new unpublished
kdiff-integrationmodule drives a
Gradle TestKit build that declares onlykdiff-annotations,kdiff-runtimeandkdiff-processor,
resolved as artifacts from a repository underbuild/, then annotates a class, compiles it and runs
the generated differ. It is the only thing in the repository that reads a generated POM — the sample
and the tutorial consume the project, so a descriptor missing a dependency was invisible to them.
It also asserts that neither KotlinPoet nor the KSP API reaches a consumer's compile classpath.
Nothing is published to~/.m2and nothing needs the network or a credential. -
The incremental-regeneration promise has a test. The same harness builds a consumer twice with an
edit between the runs, and checks both halves: that editing a declaration a differ read regenerates
that differ, and that an unrelated annotated class's generated file is left exactly as the first
build wrote it.0.7.0fixed a real bug here, found by reasoning, because nothing could catch it. -
Every message
docs/errors.mdquotes is checked against the source that emits it.
DocumentationMessagesSpeccuts each source message into the runs of text the code states literally
and requires the page's quotation to contain them in order, so rewording a diagnostic without editing
the page failscheck.CONTRIBUTING.mdloses the paragraph that admitted this hole. -
The toolchain versions the README names are pinned to the build. The
kotlin("jvm")and KSP
plugin versions in the install snippet, and the Kotlin badge, now fail the build when they name
something the build does not use — the same treatment the published coordinates already had, applied
to the other half of the block a reader copies. -
The recorded API and the promised API are stated as two different things.
docs/api-stability.md§1 now names the entries the ABI dump records
without promising: a declaration published only so that aninlinefunction can reach it
(ChangeRoutes.register,ChangeRoutes.dispatch,ElementRoutes.dispatchand the routing classes'
constructors), and the accessors an inlinevalue classlowers to (FieldPath.box-impl,
constructor-impl, and the mangledgetPath-…on eachChangevariant). They stay in the dump, so
their removal still fails the build — that is what a mechanical dump is for. What they are not is
yours to call, and the mangled accessors are the concrete reason kdiff is not designed for Java
consumers:change.getPath()is not a method that exists. -
The three additions a minor version may make are written down, each with what code written before
it still does: a furtherPatchFailure.Reasoncase, a member onDiffer,PatcherorTracked
carrying its own implementation, and a further capability interface on a generated object. A
hand-writtenobject : Patcher<T>inherits a defaulted member, andDiffer<T> { … }keeps
converting, because afun interfaceadmits non-abstract members. A member without an
implementation stays breaking. -
A Kotlin and KSP compatibility policy, which the repository previously stated nowhere. The
runtime and annotations require a Kotlin compiler at least the version they were built with, because
an older one cannot read their metadata; the processor expects the KSP plugin matching your Kotlin
version, which is the one you apply anyway. A Kotlin minor is tracked by a kdiff minor, and only
the declared pair is tested. → Kotlin and KSP -
Two questions in the FAQ: Which Kotlin version do I need? and What may change in a
kdiff minor? -
An experimental opt-in tier is recorded as declined, with the reasoning and its cost — an
addition in1.xis permanent from the release that makes it, which is a reason to add slowly rather
than a reason for a marker every call site would carry.
Changed
-
BREAKING —
PatchFailure.Reasonis no longer a closed vocabulary.0.7.0said it was sealed
and closed, on the same terms asChange: fourteen cases, and adding one breaking. That is
retracted. It stays sealed — nothing outside kdiff declares a case, and every case still carries the
facts it knows as properties — but a minor version may now declare a further reason. A reason
accompanies a newly supported shape, and both releases since the vocabulary was declared wanted new
ones; freezing the set for the length of1.xwould make the next supported shape choose between a
major version and a reason that misdescribes itself.Changeis unaffected and stays closed. The two differ in what a caller does with them: a change is
what routing dispatches on, so exhaustive handling is the reason the type is sealed at all; a reason
is what a caller reports.Nothing about this release's behaviour changes — there are still fourteen cases and none has moved.
What changes is what a later release may do, which is why the loosening lands before1.0.0rather
than after it.Migration: if you match reasons exhaustively with no
else, add one. Such awhencompiles against
this release and against every release that adds nothing; the release that adds a case makes it a
compile error on recompilation, and — because Kotlin lowers an exhaustivewhento a throw on the
branch it believes unreachable — aNoWhenBranchMatchedExceptionif the code is not recompiled. A
caller that only logs or renders failures needs no change at all:PatchFailure.toString()covers
every case, including one declared after your code was written. -
BREAKING —
DiffProcessorisinternal.kdiff-processor's recorded API is now the single
declaration its artifact exists to provide,DiffProcessorProvider, which the compiler loads through
the module's service registration. A dump of exactly one entry is what fails the build the day a
helper class becomes public by accident.Marked breaking because a recorded public declaration disappears, which this project treats as
breaking without exception. Migration: none — a consumer that namedDiffProcessorwas constructing
a symbol processor outside a compiler. -
JVM 21 is stated as the platform kdiff targets, rather than as fallout from
inline. No
requirement moved and no code changed: a consuming module still needs a JDK 21 or later and a JVM
target of 21. What changed is that the README and the FAQ derived that from the builder entry points
beinginline, which read as an accident with a workaround behind it. It is a choice, and two things
follow from it — class files a JVM 21 loads, andinlineentry points a consumer must compile at
target 21 to use. Those entry points arediffer { },trackScope { },tracker { },Diff.route
andChangeRoutes.onEach, so the requirement reaches a consumer who only routes a generated differ's
diff, not just one who writes a differ by hand. -
Every promise in
docs/api-stability.mdnow says when it takes effect: at1.0.0. Until then a
minor version may break anything on that page, including a promise on it — which is what writing it
before the tag is for.
Coordinates
implementation("io.github.rcapraro:kdiff-annotations:1.0.0")
implementation("io.github.rcapraro:kdiff-runtime:1.0.0")
ksp("io.github.rcapraro:kdiff-processor:1.0.0")kdiff 0.7.0
Two things a stranger meets before the first line of Kotlin: the coordinates, and how much annotating
a domain model costs. This release settles both — kdiff resolves from Maven Central with no repository
block and no token, with sources and KDoc reaching the IDE, and the value types a domain model is full
of now compare by equality with nothing declared on them.
It also writes down what 1.0.0 will promise, in docs/api-stability.md,
before the tag rather than after it.
Added
-
The types a domain model is full of are values now, with no annotation.
BigDecimal,
BigInteger,UUID,Currency,Locale,URI, everyjava.timevalue type (Instant,
LocalDate,LocalTime,LocalDateTime,OffsetDateTime,OffsetTime,ZonedDateTime,
Duration,Period,Year,YearMonth,MonthDay,ZoneId,ZoneOffset),kotlin.time.Instant
andkotlin.uuid.Uuidare compared by equality wherever they appear — as a property, as a list
element, as a map value. The criterion is written down so the list can be read rather than
remembered: immutable, equal by value, from the JDK or the Kotlin standard library.java.util.Date
is deliberately not on it.Each of these previously cost an
objectimplementingDifferplus a@DiffWithon every property
holding one, to express the one comparison kdiff already knew how to do. If you wrote such a differ,
you may delete it and the annotation; nothing forces you to.One caveat in writing:
BigDecimal'sequalsis scale-sensitive, so10and10.00report a
change. A numeric comparison is still a@DiffWithdiffer's business. -
An inline
value classis a value. A class declaredvalue classis compared by equality
wherever it appears, with no annotation on it or on the property.kotlin.time.Durationis one, so
it needs no place on the list above. -
@DiffAsValuedeclares that a type, or one property, compares as a single value. On a class,
every property, list element and map value of that type is compared by equality, in every
@Diffableclass that reaches it — the per-type declaration the FAQ used to say did not exist. On a
property, that property alone is compared as one value whatever its type:@DiffAsValue val billing: Addressreports one change atbilling, and@DiffAsValue val tags: List<String>one change at
tags. It is the annotation counterpart of thediffer { }builder'sfield, so the annotated and
hand-written routes can once again describe the same model.A
@DiffAsValuethat could change nothing is a compile error — on an enum, avalue class, or a
property whose type is already a value — and so is one that contradicts another declaration: beside
@Diffableon a class, or beside@DiffWithor@DiffIgnoreon a property. -
The "cannot compare" diagnostic now names three ways out instead of two:
@Diffableon the type,
@DiffAsValueon the property, or@DiffWith. -
DifferandPatcherare functional interfaces. A one-off differ can be written where it is
used —Differ<Money> { before, after -> … }— and so can a patcher. Source- and binary-compatible;
the recorded API does not move. The descent-bound caveat anobject : Differ<T>already carried
applies to the lambda form too, and is now stated for both. -
Segment.Key.MAP_ENTRYis the declared name a map entry's key segment carries, replacing the
"key"literal in the runtime. Its value is unchanged, so every renderedamounts[key=eur]and
every assertion on one reads as before — but a hand-written differ or patcher building a map path can
now name the constant, and a reader of that path can find wherekeycomes from. -
docs/api-stability.mdstates what1.0.0will promise: the recorded
public API of the three published modules, the two closed vocabularies and the three declared
exception types, which types are data classes and by what criterion, what generated code guarantees
and what in a generated file is not API, what is explicitly not API at all, and the JVM platform. It
also records the questions answered "no" — a typedDiff<T>,TypeChangedcarrying classes rather
than names, a common exception supertype — each with its reason.
Changed
-
BREAKING — kdiff is published to Maven Central, and the group id changes. A consumer needs
mavenCentral(), which every Gradle build already declares, and nothing else: no repository block, no
personal access token, no401. GitHub Packages is no longer published to.The group id is now
io.github.rcapraro— a namespace Central can verify against the maintainer's
GitHub account, whichio.github.kdiffis not. Package names do not change, so no import moves:// before — GitHub Packages, with a repository block and a token implementation("io.github.kdiff:kdiff-annotations:0.6.0") implementation("io.github.kdiff:kdiff-runtime:0.6.0") ksp("io.github.kdiff:kdiff-processor:0.6.0") // after — Maven Central, nothing else to declare implementation("io.github.rcapraro:kdiff-annotations:0.7.0") implementation("io.github.rcapraro:kdiff-runtime:0.7.0") ksp("io.github.rcapraro:kdiff-processor:0.7.0")
Migration: change the group id on the three coordinates and delete the
maven.pkg.github.com/rcapraro/kdiffrepository block, along with thegpr.user/gpr.key
properties it read. Imports stayio.github.kdiff.*. -
Every published module now ships a sources jar and a documentation jar, both signed, as Central
requires. Go to declaration onDiffer,Changeor any runtime helper lands on the Kotlin source
with its KDoc instead of decompiled bytecode — the KDoc inkdiff-runtimecarries the why of every
helper, and until now it was only readable by cloning the repository. -
BREAKING —
Patched<T>is removed; the patch helpers returnPatchResult<T>.patchValue,
patchNested,patchNestedNullable,patchNullable,patchKeyedList,patchPositionalList,
patchSet,patchMap,unpatchableandnotConstructorPropertyall return the type a
Patcher.applyreturns, so a helper's result carriesisCleanandgetOrThrow()too. The two types
had identical shape, andpatchNestedexisted partly to convert one into the other.Migration: rename
PatchedtoPatchResult; the members are the same. A hand-written patcher that
only reads.valueand.failures— which is every example in the docs — compiles unchanged, and so
does generated code, which reads the same two members. -
BREAKING —
Diffis no longer a data class. It keeps its constructor, itschanges, its
equality over those changes and every member it had; it losescopyandcomponent1, which would
have fixedDiff's shape forever. ItstoString()is now the textrender()produces, so a diff
printed in a log line or an assertion failure reads as a diff — over several lines when it holds
several changes.Migration: build a new
Diff(changes)instead ofdiff.copy(changes = …), and readdiff.changes
instead of destructuring. A caller that wants a diff on one line haschanges.toString()orsize.
Fixed
-
BREAKING — a comparison annotation on a property a
@Diffablesealed parent declares is now
honoured by a subclass that overrides it. Kotlin puts none of an overridden declaration's annotations
on theoverride, so@DiffAsValue,@DiffIgnoreand@DiffWithon a sealed parent's property held
across a subclass swap — where the parent's own properties are compared — and were silently ignored
for two instances of one subclass, which is the common case. If your model looks like this, its diffs
change:@Diffable sealed interface Shipment { @DiffAsValue val origin: Address } @Diffable data class Parcel(override val origin: Address, val tracking: String) : Shipment
Two
Parcels differing insideoriginnow report one change atorigin; they reported changes at
origin.cityand the like before.@DiffIgnorethere now excludes the property where it did not.Migration: nothing to edit — the new behaviour is what the annotated source always asked for. But a
test or a consumer asserting on the old paths sees them move, so check any assertion naming a path
beneath such a property. To keep the old behaviour, remove the annotation from the parent; to have it
apply to one subclass only, annotate that subclass's override, which wins over the parent's. -
A generated differ is now regenerated when a
value classorenum classit compared as a value is
edited. Only@DiffAsValuerecorded the declaring file before, so an incremental build could keep a
differ that a clean build refuses: redeclaring@JvmInline value class Sku(val code: String)as an
ordinary type left the generatedcompareValue("sku", …)in place and compiling, while a clean build
failed with kdiff cannot compare sku of type demo.Sku. Builds now agree; the cost is that editing
such a type regenerates the differs that read it.
Nothing else that compiled before means anything different, and the generated code for an existing
class is unchanged unless it is a subclass overriding an annotated property. kdiff-annotations gains
one annotation; kdiff-runtime's recorded API moves exactly where the two breaks above say it does,
and kdiff-processor adds no public declaration.
Coordinates
implementation("io.github.rcapraro:kdiff-annotations:0.7.0")
implementation("io.github.rcapraro:kdiff-runtime:0.7.0")
ksp("io.github.rcapraro:kdiff-processor:0.7.0")About this release run
This was the first release published to Maven Central, and it needed one manual step. The tagged run
failed at the upload with Invalid token — the Central Portal user token was rejected — and published
nothing. The t...
kdiff 0.6.0
Three ordinary Kotlin shapes that the processor could not handle now work, and each of them failed in a
way the project's own rules forbid: an error inside generated code, an annotation that silently did
nothing, or two diagnostics pointing at each other with no way through.
Added
-
A nullable collection property is compared and applied.
List<E>?,Set<E>?andMap<K, V>?
follow the rule a nullable nested property already followed: a null on one side is oneValueChanged
at the property carrying both sides, two nulls are no change, and two present collections are compared
as that collection always is — by key, by position, by membership or by entry key. Applying mirrors it:
a change at the property sets it wholesale, and a change beneath a null one is reported as
NothingBeneathNull.Before this, such a property made the generated file fail to compile, with a type mismatch in a file
the author had not written. The hand-written route gains it through the same builders —list,
keyedList,setandmapnow accept a nullable property exactly asnesteddid, so no call site
changes. -
An
objectsubclass of a@Diffablesealed type needs no annotation.data object Unpaid : Paymentalongside@Diffable data class Card(...)now compiles and is dispatched on: two references
to one singleton report nothing, and a swap to or from it reports the type change and the sealed
parent's own properties, as any subclass swap does. Applying a change addressed beneath a singleton
reports it as naming a property the type does not have.Before this the shape was a dead end —
@Diffablerejected the object, and the sealed parent required
every subclass to carry it.@Diffableon the object stays an error, and now says the object needs no
annotation of its own. -
A collection whose elements are nullable and reached through a differ is a compile error at the
property, naming the element type and the two ways out, rather than an error inside generated code.
List<String?>is unaffected, because equality is defined for null, and so is everySet. -
Two new runtime helpers,
patchNullableandpatchSingleton, which is the whole of the public API
change. Widening the four collection compare helpers to accept a nullable side is source- and
binary-compatible, so nothing else in the dump moved.
Changed
-
BREAKING —
@DiffKey,@DiffIgnoreand@DiffWithon a property of a class that is not
@Diffablenow fail the build, naming the property and the missing annotation. They are only ever read
off a@Diffableclass, so anywhere else each one silently configured nothing while its author
believed comparison was set up. This is the rule the tracking annotations have carried since0.1.0,
applied to the three that lacked it.Migration: add
@Diffableto the class, or remove the annotation. A module carrying a stray one
compiled before and does not now, which is the point. -
BREAKING —
@DiffWithtogether with@DiffIgnoreon one property is rejected as a conflict: an
ignored property is never compared, so a differ named for it could never run.@DiffKeybeside
@DiffIgnoreis deliberately not a conflict and still compiles — a key identifies the element
while@DiffIgnorekeeps it out of that element's own comparison, which is what you want. -
A change addressed to a nullable nested property that a value change at that same property
replaces wholesale is now reported asNotApplicableToValueinstead of being dropped in silence. The
rebuilt value is unchanged; what changes is thatfailuresaccounts for every change it was given,
which is what the library promises everywhere else. A caller asserting on an emptyfailureslist for
such a diff sees the new entry.The same rule now covers nullable lists, sets and maps, which is where it was noticed: a wholesale
replacement makes the property a value for that application, so the last change at it wins and the
rest could not be used. -
The generated API surface is otherwise unchanged. A class that compiles today regenerates
byte-identically: the nullable-collection wrapper and the singleton branches appear only for shapes
that could not compile before.
kdiff 0.5.0
One comparison behaves differently and nothing else moves: kdiff-runtime compares an unkeyed list by
excluding the tail the two sides already agree on. No signature changed, no annotation means anything
different, the processor is untouched, and an annotated class compiles to byte-identical generated
code — so upgrading is a re-baselining of assertions over unkeyed lists that change length, and nothing
else.
Changed
-
BREAKING — an unkeyed list whose length differs between the two sides now reports the insertion
or deletion instead of a cascade. Before comparing, kdiff excludes the tail the two lists already
agree on, so one contiguous edit — at the head, in the middle, or at the tail — reports as exactly
those additions or removals.before = ["a", "b", "c"] was now after = ["x", "a", "b", "c"] 3 value changes + 1 addition 1 addition, at index 0Nothing in the API moved: no signature changed, no annotation means anything different, the
processor is untouched and generated code is byte-identical. What changed is the content of the
diff for that one case.Migration: re-baseline assertions over unkeyed lists that change length. Two lists of the same
length are unaffected — they are still compared index by index, and that is now a stated guarantee
rather than an accident, because for a fixed-arity list (seven weekday slots, a coordinate triple, a
three-place ranking) the index is the element's identity.Two scattered edits still smear:
["a","b","c"] -> ["x","a","b","c2"]agrees at neither end, and
recovering it would need the edit-distance search kdiff's linear bound rules out.@DiffKeyremains
the answer wherever elements have an identity.A position is excluded only when comparing it would report nothing — equality where elements are
compared as values, the differ reporting no change where they are compared by a differ. A type whose
equalsis looser than the properties its differ reads is therefore still compared in full.
kdiff 0.4.0
What changes here is kdiff-runtime's API. No annotation means anything different, the processor is
untouched, and an annotated class compiles to byte-identical generated code — so upgrading is a
recompile of the code you wrote around the generated code, guided by the breaking items below.
Added
-
A diff is a collection.
DiffimplementsIterable<Change>and gainssize,isNotEmpty(),
plus, avarargconstructor andDiff.EMPTY, so the standard library's operators apply to a diff
directly instead of throughchanges. It is deliberatelyIterablerather thanList: aDiff
equals only anotherDiff, and advertising list-ness while equalling no list would mislead. -
A diff narrows by property reference.
diff.at(Order::billing)keeps the change reported at that
property;diff.under(Order::billing)keeps that plus everything beneath it. Both return aDiff,
so they compose with each other and with+, and neither matches a string — renaming the property
reaches the call site.Name the type —
diff.at<Order>(Order::billing)— to have the property checked against it.Diff
carries no type argument, so an inferred type parameter comes from the property alone and
orderDiff.at(Address::street)compiles and matches nothing.route<Order> { }has no such hole. -
PatchResult.getOrThrow()returns the value when every change applied and raises
PatchFailedException, carrying every failure, when any did not. Applying itself stays partial —
that is what lets a caller salvage what applied — so all-or-nothing is now a choice at the call site. -
A cyclic structure is reported instead of overflowing the stack. Comparing and applying descend
at mostMAX_DESCENT(512) nested levels and then raiseCyclicStructureException, naming the path
they stopped at and saying whether an instance was re-entered — a cycle — or the structure is simply
deeper than kdiff descends. This costs about 9% of comparison throughput on a model with nested
@Diffableproperties and 13% at six levels of nesting; collections and flat types are unaffected. -
Builder blocks are scoped and run exactly once.
@KdiffDslcloses every builder scope, so a
block nested in another can no longer reach the outer builder's members — arouteframe calling the
enclosing routing'sonused to compile and quietly register a handler against the wrong frame. Each
builder function also declarescallsInPlace(block, EXACTLY_ONCE), so avalcan be assigned inside
a block and read after it. -
Build gates.
./gradlew checknow also runs ktlint, detekt,allWarningsAsErrorsand ABI
validation against a checked-in API dump per published module, so removing a public declaration fails
the build rather than reaching a consumer.dokkaGeneratebuilds reference documentation, and is
deliberately not part ofcheck. See CONTRIBUTING.md.
Changed
-
BREAKING —
PatchFailure.reasonis a sealedPatchFailure.Reasoninstead of aString. The
fourteen reasons the runtime reports are now declared cases, each carrying what it knows — the
property, the key, the index — so a caller can branch on why a change did not apply instead of
matching prose.Migration:
failure.reason == "no element with this key to patch"becomes
failure.reason is PatchFailure.Reason.NoElementForKey. A failure logged as text is unchanged:
PatchFailure.toString()renders the same sentence it always did. LikeChange, the vocabulary is
closed, so adding a case is itself breaking. -
BREAKING (source only) —
Diff.isEmptyis now a function,isEmpty(), to match the shape the
standard library uses for every other collection.Migration: add the parentheses. Kotlin compiles both a
val isEmpty: Booleanand a
fun isEmpty(): Booleanto the sameisEmpty()Zsignature, so this is a recompile rather than a
link error — already-compiled callers keep working.DiffNode.isEmptyis unchanged and remains a
property. -
BREAKING — the duplicate-
@DiffKeyrefusal raisesDuplicateDiffKeyException, carrying the list
property, the key property and the duplicated key value as inspectable properties. It is an
IllegalArgumentException, which is what was raised before, so an existingcatchkeeps working and
the message is unchanged. -
BREAKING —
TrackScopeBuilderandTrackerBuilderdeclare their five shared members through one
ScopeDeclaration<T>interface over one implementation, rather than one re-declaring and forwarding
the other's. Source-compatible for every call site inside a builder block; binary-incompatible, which
is what the new API dump exists to make visible. -
BREAKING — a
differ { }naming no property and no subtype is rejected where it is built. Such a
differ reported every pair of instances as equivalent however much they differed, which is the same
mistake the processor already rejects for an annotated class with nothing to compare. -
BREAKING — consumers must compile at JVM target 21. The builder entry points are
inlineso they
can statecallsInPlace, and Kotlin will not inline bytecode built for a higher target than the
module being compiled. Loading these classes already required a JVM 21; compiling against them now
does too. -
The generated API surface is unchanged. No annotation means anything different, the processor is
untouched, and a@Diffableclass compiles to byte-identical generated code — the breaking items
above are all inkdiff-runtime, in what a consumer writes around that code.
kdiff 0.3.1
Changed
-
The runtime's three hot paths — comparison, application and tracking — allocate substantially less
for the same results. No API is removed or changed, no annotation means anything different, and the
change vocabulary is untouched: upgrading is a version bump.Comparison builds a lifted path in one copy instead of two and lifts a nested collection change once
instead of twice; sets are compared by membership rather than by building two intermediate sets; a
keyed list indexes by key without wrapping every element, and detects a repeated key from that index
rather than from a second walk. Applying returns a property no change addresses as the source
instance rather than rebuilding it. Tracking resolves each property's depth once when the scope is
resolved, so deciding whether to report a change allocates nothing. Routing groups changes once
instead of scanning them once per handler. -
Applying a diff no longer rebuilds a property that nothing in the change list addresses: the source
instance is carried through, as it already was for a nested value and for a property excluded from
comparison. Results compare equal either way; what changes is that the rebuilt object now shares an
untouched collection with its source, exactly ascopy()already did for every property a patcher
does not name.The one precondition this does not skip is a keyed list holding a repeated key, which is still
rejected whether or not a change addresses it. -
A generated
applydeclares the set of compared property names once as a private property instead
of rebuilding it on every call, and gathers its failures into one list instead of folding them with
+. The generated API is unchanged — the new property is private — but regenerating is needed to
pick this up.
Added
kdiff-benchmarks, an unpublished module carrying JMH benchmarks for comparison, application,
tracking and routing. It is deliberately not part of./gradlew check; run it with
./gradlew :kdiff-benchmarks:jmh, adding-Pjmh.profilers=gcfor allocation rate.
kdiff 0.3.0
Added
-
A
@DiffKeyvalue must identify at most one element in a list. Comparing or applying a keyed list
whose elements share a key throwsIllegalArgumentException, naming the property, the key property
and the duplicated value.Such a list has no diff to report: a path identifies a keyed element by its key value, so
addresses[id=A1]cannot say which of two elements it means, and distinguishing them would need a
change variant the closed vocabulary does not have. It cannot be caught at compile time either —
uniqueness is a property of the data, not of the declaration — so it is a runtime precondition, and
both directions enforce it identically.If a key is not unique it is not an identity: drop
@DiffKeyfrom the element type, or describe the
property withlistrather thankeyedList, and the list is compared by position instead, giving up
move reporting. -
patchKeyedListtakesnameandkeyProperty, so it can name the offending property in that
message. Both default to null; a hand-writtenPatcherthat omits them gets a message without them.
Generated code passes both.
Changed
-
The guides are corrected and extended: diagrams for the change and path model,
underframe
dispatch, depth counting, the module graph and the tutorial's command-to-events flow; a
"Where to go next" footer on every page; sections indocs/architecture.mdon what a comparison
costs and what kdiff does not do; andDiffNode/tree()given a worked example. -
Documented coordinates are pinned to the published version by a test, so an install snippet cannot
survive a release that leaves it behind.