
Diff a nivel de campo para auditoría generado en compile-time sin reflexión: rutas legibles, redacción de campos sensibles, diff de colecciones por identidad (@AuditKey) y serialización.
Diff a nivel de campo para auditoría, en compile-time y sin reflexión. Dadas dos instancias de la misma data class (antes/después), genera la lista de cambios con paths legibles, redacción de campos sensibles y diff de colecciones por identidad, no por posición. Para changelogs de entidades, event sourcing y trazas de auditoría.
Core de dominio puro (hexagonal) sobre genkit + frontend KSP2. Compatible con GraalVM native-image y Kotlin Multiplatform: lo que la competencia reflexiva (JaVers) estructuralmente no puede ofrecer.
v0.1 funcionalmente completo (io.github.kuroxbyte:kaudit-*, Apache-2.0). 36 tests
en verde (dominio sin compilar + end-to-end con compilación real KSP2). kaudit-annotations
y kaudit-runtime son Kotlin Multiplatform (JVM, JS, Native). Pendiente solo el ciclo
de release (Maven Central) y las coordenadas definitivas.
Verificado por test: cero reflexión en el código generado (ZeroReflectionTest — la base
de la compatibilidad GraalVM native-image) y aislamiento incremental (IncrementalIsolationTest
— cada diff generado depende solo de su propia fuente). El build native-image real es un paso
de CI (requiere GraalVM).
Panorama: el incumbente es JaVers (Java, reflexivo, JVM). KAudit gana en
native-image/KMP y en no pagar reflexión en runtime; JaVers gana hoy en madurez y features
(repositorio de snapshots, consultas). El único competidor "diff Kotlin" nativo,
entdiffy, está abandonado.
// build.gradle.kts
plugins {
kotlin("jvm") version "2.1.21"
id("com.google.devtools.ksp") version "2.1.21-2.0.1"
}
dependencies {
implementation("io.github.kuroxbyte:kaudit-annotations:0.2.0")
implementation("io.github.kuroxbyte:kaudit-runtime:0.2.0") // FieldChange, ChangeKind
ksp("io.github.kuroxbyte:kaudit-processor:0.2.0")
}# gradle.properties
ksp.useKSP2=trueRequisitos: JDK 17+.
| Módulo | Rol |
|---|---|
kaudit-annotations |
API pública: @Auditable, @AuditKey, @Sensitive, @AuditIgnore. Cero deps, @Retention(SOURCE). |
kaudit-runtime |
FieldChange, ChangeKind. Cero deps de framework. |
kaudit-core |
DOMINIO puro: AuditModel, FieldStrategy, build. Sin KSP ni KotlinPoet (verificado con Konsist). El emisor vive en el frontend. |
kaudit-processor |
COMPOSICIÓN: único módulo con SymbolProcessorProvider. Cablea kspkit + kaudit-core. |
kaudit-serialization |
OPCIONAL: FieldChangeRecord (@Serializable) + toRecords() para persistir trazas con kotlinx.serialization. |
kaudit-apt |
Variante Java (javac annotation processor): clases Java @Auditable → XAuditor.diff(a, b). Reutiliza kaudit-core; emite Java (JavaPoet). Paridad completa con KSP. |
kaudit-spring |
Núcleo de la integración Spring, agnóstico de persistencia: AuditRecord, SPI AuditWriter, KAuditOperations. |
kaudit-spring-data-jpa |
Auditoría automática en JPA: listener + snapshot en @PostLoad. |
kaudit-spring-data-r2dbc |
Camino explícito reactivo (R2DBC no da estado previo). |
kaudit-spring-boot-starter |
Auto-configuración: detecta JPA o R2DBC por classpath. Ver docs/spring.md. |
kaudit-samples-spring |
App Spring Boot ejecutable: se audita sola, con snapshot Kotlin (KSP) y Java (APT). ./gradlew :kaudit-samples-spring:run. No se publica. |
kaudit-benchmarks |
JMH: KAudit (codegen) vs JaVers (reflexión). No se publica. |
kaudit-samples |
Ejemplos EJECUTABLES (Kotlin/KSP + Java/APT). ./gradlew :kaudit-samples:run. No se publica. |
kaudit-integration-tests |
Consumidor REAL end-to-end: aplica KSP y llama al diff() generado directamente (sin reflexión). No se publica. |
kaudit-incremental-tests |
Incrementalidad de KSP (Gradle TestKit): un consumidor real verifica que tocar una clase ajena NO regenera el archivo. No se publica. |
kaudit-samples-spring.run): docs/ejemplos.md · fuente en kaudit-samples.mkdocs serve. Cambios: CHANGELOG.md.@Auditable
data class Account(
@AuditKey val id: Long,
val name: String,
@Sensitive val apiKey: String,
@AuditIgnore val lastSeenAt: Instant,
val owner: Person, // @Auditable → recursivo (path con punto)
val members: List<Member>, // elemento con @AuditKey → diff por identidad
)
val changes: List<FieldChange> = old.diff(new)old.diff(new) es una extensión generada (fun Account.diff(other: Account): List<FieldChange>),
en el mismo paquete que la clase. Legible y descubrible desde el IDE.
// FieldChange(path="name", old="Ann", new="Anna")
// FieldChange(path="apiKey", old=null, new=null, redacted=true)
// FieldChange(path="owner.email", old="a@x.com", new="b@x.com")
// FieldChange(path="members[42].role", old="viewer", new="admin") // 42 = @AuditKey, no índice| Anotación | Objetivo | Efecto |
|---|---|---|
@Auditable |
clase | genera diff; habilita recursión al aparecer como tipo de propiedad. |
@AuditKey |
propiedad | identidad para diff de colecciones por clave (no por posición). |
@Sensitive |
propiedad | reporta redacted = true con ambos valores en null. |
@AuditIgnore |
propiedad | excluye la propiedad del diff. |
@AuditName |
propiedad | fija la etiqueta del path (estable ante renombrados de la propiedad). |
@AuditInclude |
propiedad | activa modo opt-in: solo se auditan las propiedades marcadas. |
Las anotaciones de campo apuntan a @Target(PROPERTY) a propósito: así KSP las lee en la
propiedad y no en el parámetro de constructor.
KSP solo procesa Kotlin. Para clases Java existe kaudit-apt, un annotation processor de
javac que reutiliza el mismo dominio (kaudit-core) y emite Java (métodos estáticos, ya que
Java no tiene extension functions). Funciona con records y POJOs (getters):
@Auditable
public record Account(long id, String name, @Sensitive String apiKey, Person owner) {}
@Auditable
public record Person(String email) {}
List<FieldChange> changes = AccountAuditor.diff(oldAccount, newAccount);
// FieldChange(path="owner.email", ...), FieldChange(path="apiKey", redacted=true), ...// build.gradle (proyecto Java): registrar el processor con annotationProcessor(...)
dependencies {
implementation("io.github.kuroxbyte:kaudit-annotations:0.2.0")
implementation("io.github.kuroxbyte:kaudit-runtime:0.2.0")
annotationProcessor("io.github.kuroxbyte:kaudit-apt:0.2.0")
}Es el pago de la arquitectura hexagonal: un frontend distinto (aptkit: javax.lang.model
→ genkit-model) sobre el mismo core, y un emisor que usa JavaPoet (simétrico a KotlinPoet
en el lado Kotlin, aislado en genkit-emit). Paridad completa con la variante Kotlin: escalar,
@Sensitive, @AuditIgnore, @AuditName, @AuditInclude, anidado @Auditable, colecciones
opacas, colecciones por @AuditKey (clave simple y compuesta), Map por clave natural y
tipos sealed (vía instanceof pattern, sin reflexión). Probado end-to-end corriendo javac
con el processor (kaudit-apt: 5 tests e2e). Ejemplos ejecutables en kaudit-samples.
data class FieldChange(
val path: String,
val old: Any?,
val new: Any?,
val redacted: Boolean = false,
val kind: ChangeKind = ChangeKind.MODIFIED,
)
enum class ChangeKind { MODIFIED, ADDED, REMOVED }@AuditKey en el elemento: una lista que cambió produce un FieldChange con
la lista entera (old vs new). El índice cambia al reordenar y generaría ruido falso.@AuditKey: diff por identidad. Alta → ADDED; baja → REMOVED; cambio en un
elemento con la misma clave → MODIFIED con path por clave (members[42].role).
Reordenar sin cambiar contenido → sin cambios. Este es el detalle que separa auditoría
útil de basura. Admite clave compuesta (varias propiedades @AuditKey → lines[[1, A]]).Map<K, V>: diff por la clave natural del mapa (no hace falta @AuditKey).
ADDED/REMOVED/MODIFIED con path settings[clave]; si el valor es @Auditable, los
MODIFIED se recorren campo a campo (byId[7].role).sealed @Auditable: si ambos lados son el mismo subtipo, se recorre
(status.reason); si el subtipo cambió, se reporta el objeto entero. Sin reflexión (usa is).// Changelog legible (en kaudit-runtime, sin dependencias):
println(changes.renderText())
// ~ owner.email: a@x.com -> b@x.com
// + members[30]: Member(id=30, role=guest)
// ~ apiKey: (redactado)
// Persistir como JSON (módulo kaudit-serialization + kotlinx.serialization):
val json = Json.encodeToString(changes.toRecords())@Sensitive nunca lleva el valor: redacted = true, old/new en null. Decisión
estructural, no opción de configuración — si el valor pasa "por si acaso", termina en un log.
La detección compara los valores reales; solo la emisión los redacta.
Un campo @Auditable genera owner.diff(other.owner) y prefija el path — cada tipo genera
su propio diff y se llama entre sí. Por eso un tipo autorreferencial (Node(next: Node?))
produce una función recursiva correcta sin expansión de modelo ni detección de ciclos en
build-time. Un tipo anidado no @Auditable es opaco: se compara con equals y se reporta
el objeto entero (evita explotar en Map<String, Any>).
equals estándar por defecto. Ojo con dos trampas documentadas:
BigDecimal("1.0") != BigDecimal("1.00") (equals usa scale), y Array.equals es identidad
de referencia — por eso una propiedad Array auditada es error de compilación
(kaudit.array.property): usa List o @AuditIgnore.
Errores de compilación con código estable, apuntando al símbolo correcto:
| Código | Cuándo |
|---|---|
kaudit.key.nullable |
@AuditKey sobre propiedad nullable (la identidad no puede ser null). |
kaudit.key.type |
@AuditKey sobre un tipo sin equals/hashCode de identidad clara (permitidos: primitivos, String, UUID, value class, enum). |
kaudit.array.property |
propiedad Array auditada. |
Se acumulan todos los errores de una pasada (nunca "arregla uno, descubre el siguiente").
Benchmark JMH (./gradlew :kaudit-benchmarks:jmh) del diff del mismo par antes/después,
codegen vs reflexión — KAudit es ~100× más rápido que JaVers:
Benchmark Mode Cnt Score Units
DiffBenchmark.kaudit avgt ~20 ns/op
DiffBenchmark.javers avgt ~2178 ns/op
(Cifras orientativas de una corrida corta; JaVers construye un modelo de diff más rico, así que no es 1:1, pero el orden de magnitud refleja el coste de la reflexión. El módulo de benchmarks no se publica.)
Hexagonal, sobre genkit (base neutral) + kspkit (frontend KSP):
kaudit-annotations API pública (KMP-ready, SOURCE)
kaudit-runtime FieldChange / ChangeKind (KMP-ready)
kaudit-core DOMINIO puro: ClassModel → AuditModel (sin KSP ni KotlinPoet)
├── model/ AuditModel, FieldStrategy (Direct|Redact|Recurse|OpaqueCollection|KeyedCollection|KeyedMap|SealedRecurse)
└── build/ AuditModelBuilder (+ diagnósticos de dominio)
kaudit-processor kspkit + kaudit-core + emisor KotlinPoet (único con SymbolProcessorProvider)
kaudit-apt aptkit + kaudit-core + emisor JavaPoet (variante Java)
kaudit-core no compila si se le agrega KSP o KotlinPoet (regla dura, candado Konsist). El
dominio se testea sin compilar → suites de cientos de casos en milisegundos.
./gradlew buildgenkit (la base compartida) se resuelve por composite build (includeBuild("../genkit"))
en desarrollo, y por coordenadas publicadas en release.
Renderizador a texto legible del changelog · serializador para persistir List<FieldChange> ·
puente audit-multiquery para escribir cambios en una tabla (módulo separado, jamás
dependencia del core).
Diff a nivel de campo para auditoría, en compile-time y sin reflexión. Dadas dos instancias de la misma data class (antes/después), genera la lista de cambios con paths legibles, redacción de campos sensibles y diff de colecciones por identidad, no por posición. Para changelogs de entidades, event sourcing y trazas de auditoría.
Core de dominio puro (hexagonal) sobre genkit + frontend KSP2. Compatible con GraalVM native-image y Kotlin Multiplatform: lo que la competencia reflexiva (JaVers) estructuralmente no puede ofrecer.
v0.1 funcionalmente completo (io.github.kuroxbyte:kaudit-*, Apache-2.0). 36 tests
en verde (dominio sin compilar + end-to-end con compilación real KSP2). kaudit-annotations
y kaudit-runtime son Kotlin Multiplatform (JVM, JS, Native). Pendiente solo el ciclo
de release (Maven Central) y las coordenadas definitivas.
Verificado por test: cero reflexión en el código generado (ZeroReflectionTest — la base
de la compatibilidad GraalVM native-image) y aislamiento incremental (IncrementalIsolationTest
— cada diff generado depende solo de su propia fuente). El build native-image real es un paso
de CI (requiere GraalVM).
Panorama: el incumbente es JaVers (Java, reflexivo, JVM). KAudit gana en
native-image/KMP y en no pagar reflexión en runtime; JaVers gana hoy en madurez y features
(repositorio de snapshots, consultas). El único competidor "diff Kotlin" nativo,
entdiffy, está abandonado.
// build.gradle.kts
plugins {
kotlin("jvm") version "2.1.21"
id("com.google.devtools.ksp") version "2.1.21-2.0.1"
}
dependencies {
implementation("io.github.kuroxbyte:kaudit-annotations:0.2.0")
implementation("io.github.kuroxbyte:kaudit-runtime:0.2.0") // FieldChange, ChangeKind
ksp("io.github.kuroxbyte:kaudit-processor:0.2.0")
}# gradle.properties
ksp.useKSP2=trueRequisitos: JDK 17+.
| Módulo | Rol |
|---|---|
kaudit-annotations |
API pública: @Auditable, @AuditKey, @Sensitive, @AuditIgnore. Cero deps, @Retention(SOURCE). |
kaudit-runtime |
FieldChange, ChangeKind. Cero deps de framework. |
kaudit-core |
DOMINIO puro: AuditModel, FieldStrategy, build. Sin KSP ni KotlinPoet (verificado con Konsist). El emisor vive en el frontend. |
kaudit-processor |
COMPOSICIÓN: único módulo con SymbolProcessorProvider. Cablea kspkit + kaudit-core. |
kaudit-serialization |
OPCIONAL: FieldChangeRecord (@Serializable) + toRecords() para persistir trazas con kotlinx.serialization. |
kaudit-apt |
Variante Java (javac annotation processor): clases Java @Auditable → XAuditor.diff(a, b). Reutiliza kaudit-core; emite Java (JavaPoet). Paridad completa con KSP. |
kaudit-spring |
Núcleo de la integración Spring, agnóstico de persistencia: AuditRecord, SPI AuditWriter, KAuditOperations. |
kaudit-spring-data-jpa |
Auditoría automática en JPA: listener + snapshot en @PostLoad. |
kaudit-spring-data-r2dbc |
Camino explícito reactivo (R2DBC no da estado previo). |
kaudit-spring-boot-starter |
Auto-configuración: detecta JPA o R2DBC por classpath. Ver docs/spring.md. |
kaudit-samples-spring |
App Spring Boot ejecutable: se audita sola, con snapshot Kotlin (KSP) y Java (APT). ./gradlew :kaudit-samples-spring:run. No se publica. |
kaudit-benchmarks |
JMH: KAudit (codegen) vs JaVers (reflexión). No se publica. |
kaudit-samples |
Ejemplos EJECUTABLES (Kotlin/KSP + Java/APT). ./gradlew :kaudit-samples:run. No se publica. |
kaudit-integration-tests |
Consumidor REAL end-to-end: aplica KSP y llama al diff() generado directamente (sin reflexión). No se publica. |
kaudit-incremental-tests |
Incrementalidad de KSP (Gradle TestKit): un consumidor real verifica que tocar una clase ajena NO regenera el archivo. No se publica. |
kaudit-samples-spring.run): docs/ejemplos.md · fuente en kaudit-samples.mkdocs serve. Cambios: CHANGELOG.md.@Auditable
data class Account(
@AuditKey val id: Long,
val name: String,
@Sensitive val apiKey: String,
@AuditIgnore val lastSeenAt: Instant,
val owner: Person, // @Auditable → recursivo (path con punto)
val members: List<Member>, // elemento con @AuditKey → diff por identidad
)
val changes: List<FieldChange> = old.diff(new)old.diff(new) es una extensión generada (fun Account.diff(other: Account): List<FieldChange>),
en el mismo paquete que la clase. Legible y descubrible desde el IDE.
// FieldChange(path="name", old="Ann", new="Anna")
// FieldChange(path="apiKey", old=null, new=null, redacted=true)
// FieldChange(path="owner.email", old="a@x.com", new="b@x.com")
// FieldChange(path="members[42].role", old="viewer", new="admin") // 42 = @AuditKey, no índice| Anotación | Objetivo | Efecto |
|---|---|---|
@Auditable |
clase | genera diff; habilita recursión al aparecer como tipo de propiedad. |
@AuditKey |
propiedad | identidad para diff de colecciones por clave (no por posición). |
@Sensitive |
propiedad | reporta redacted = true con ambos valores en null. |
@AuditIgnore |
propiedad | excluye la propiedad del diff. |
@AuditName |
propiedad | fija la etiqueta del path (estable ante renombrados de la propiedad). |
@AuditInclude |
propiedad | activa modo opt-in: solo se auditan las propiedades marcadas. |
Las anotaciones de campo apuntan a @Target(PROPERTY) a propósito: así KSP las lee en la
propiedad y no en el parámetro de constructor.
KSP solo procesa Kotlin. Para clases Java existe kaudit-apt, un annotation processor de
javac que reutiliza el mismo dominio (kaudit-core) y emite Java (métodos estáticos, ya que
Java no tiene extension functions). Funciona con records y POJOs (getters):
@Auditable
public record Account(long id, String name, @Sensitive String apiKey, Person owner) {}
@Auditable
public record Person(String email) {}
List<FieldChange> changes = AccountAuditor.diff(oldAccount, newAccount);
// FieldChange(path="owner.email", ...), FieldChange(path="apiKey", redacted=true), ...// build.gradle (proyecto Java): registrar el processor con annotationProcessor(...)
dependencies {
implementation("io.github.kuroxbyte:kaudit-annotations:0.2.0")
implementation("io.github.kuroxbyte:kaudit-runtime:0.2.0")
annotationProcessor("io.github.kuroxbyte:kaudit-apt:0.2.0")
}Es el pago de la arquitectura hexagonal: un frontend distinto (aptkit: javax.lang.model
→ genkit-model) sobre el mismo core, y un emisor que usa JavaPoet (simétrico a KotlinPoet
en el lado Kotlin, aislado en genkit-emit). Paridad completa con la variante Kotlin: escalar,
@Sensitive, @AuditIgnore, @AuditName, @AuditInclude, anidado @Auditable, colecciones
opacas, colecciones por @AuditKey (clave simple y compuesta), Map por clave natural y
tipos sealed (vía instanceof pattern, sin reflexión). Probado end-to-end corriendo javac
con el processor (kaudit-apt: 5 tests e2e). Ejemplos ejecutables en kaudit-samples.
data class FieldChange(
val path: String,
val old: Any?,
val new: Any?,
val redacted: Boolean = false,
val kind: ChangeKind = ChangeKind.MODIFIED,
)
enum class ChangeKind { MODIFIED, ADDED, REMOVED }@AuditKey en el elemento: una lista que cambió produce un FieldChange con
la lista entera (old vs new). El índice cambia al reordenar y generaría ruido falso.@AuditKey: diff por identidad. Alta → ADDED; baja → REMOVED; cambio en un
elemento con la misma clave → MODIFIED con path por clave (members[42].role).
Reordenar sin cambiar contenido → sin cambios. Este es el detalle que separa auditoría
útil de basura. Admite clave compuesta (varias propiedades @AuditKey → lines[[1, A]]).Map<K, V>: diff por la clave natural del mapa (no hace falta @AuditKey).
ADDED/REMOVED/MODIFIED con path settings[clave]; si el valor es @Auditable, los
MODIFIED se recorren campo a campo (byId[7].role).sealed @Auditable: si ambos lados son el mismo subtipo, se recorre
(status.reason); si el subtipo cambió, se reporta el objeto entero. Sin reflexión (usa is).// Changelog legible (en kaudit-runtime, sin dependencias):
println(changes.renderText())
// ~ owner.email: a@x.com -> b@x.com
// + members[30]: Member(id=30, role=guest)
// ~ apiKey: (redactado)
// Persistir como JSON (módulo kaudit-serialization + kotlinx.serialization):
val json = Json.encodeToString(changes.toRecords())@Sensitive nunca lleva el valor: redacted = true, old/new en null. Decisión
estructural, no opción de configuración — si el valor pasa "por si acaso", termina en un log.
La detección compara los valores reales; solo la emisión los redacta.
Un campo @Auditable genera owner.diff(other.owner) y prefija el path — cada tipo genera
su propio diff y se llama entre sí. Por eso un tipo autorreferencial (Node(next: Node?))
produce una función recursiva correcta sin expansión de modelo ni detección de ciclos en
build-time. Un tipo anidado no @Auditable es opaco: se compara con equals y se reporta
el objeto entero (evita explotar en Map<String, Any>).
equals estándar por defecto. Ojo con dos trampas documentadas:
BigDecimal("1.0") != BigDecimal("1.00") (equals usa scale), y Array.equals es identidad
de referencia — por eso una propiedad Array auditada es error de compilación
(kaudit.array.property): usa List o @AuditIgnore.
Errores de compilación con código estable, apuntando al símbolo correcto:
| Código | Cuándo |
|---|---|
kaudit.key.nullable |
@AuditKey sobre propiedad nullable (la identidad no puede ser null). |
kaudit.key.type |
@AuditKey sobre un tipo sin equals/hashCode de identidad clara (permitidos: primitivos, String, UUID, value class, enum). |
kaudit.array.property |
propiedad Array auditada. |
Se acumulan todos los errores de una pasada (nunca "arregla uno, descubre el siguiente").
Benchmark JMH (./gradlew :kaudit-benchmarks:jmh) del diff del mismo par antes/después,
codegen vs reflexión — KAudit es ~100× más rápido que JaVers:
Benchmark Mode Cnt Score Units
DiffBenchmark.kaudit avgt ~20 ns/op
DiffBenchmark.javers avgt ~2178 ns/op
(Cifras orientativas de una corrida corta; JaVers construye un modelo de diff más rico, así que no es 1:1, pero el orden de magnitud refleja el coste de la reflexión. El módulo de benchmarks no se publica.)
Hexagonal, sobre genkit (base neutral) + kspkit (frontend KSP):
kaudit-annotations API pública (KMP-ready, SOURCE)
kaudit-runtime FieldChange / ChangeKind (KMP-ready)
kaudit-core DOMINIO puro: ClassModel → AuditModel (sin KSP ni KotlinPoet)
├── model/ AuditModel, FieldStrategy (Direct|Redact|Recurse|OpaqueCollection|KeyedCollection|KeyedMap|SealedRecurse)
└── build/ AuditModelBuilder (+ diagnósticos de dominio)
kaudit-processor kspkit + kaudit-core + emisor KotlinPoet (único con SymbolProcessorProvider)
kaudit-apt aptkit + kaudit-core + emisor JavaPoet (variante Java)
kaudit-core no compila si se le agrega KSP o KotlinPoet (regla dura, candado Konsist). El
dominio se testea sin compilar → suites de cientos de casos en milisegundos.
./gradlew buildgenkit (la base compartida) se resuelve por composite build (includeBuild("../genkit"))
en desarrollo, y por coordenadas publicadas en release.
Renderizador a texto legible del changelog · serializador para persistir List<FieldChange> ·
puente audit-multiquery para escribir cambios en una tabla (módulo separado, jamás
dependencia del core).