Skip to content

Latest commit

 

History

17 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

KAudit

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.

Estado

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.

Instalación (JVM)

// 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=true

Requisitos: JDK 17+.

Módulos

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 @AuditableXAuditor.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.

Documentación

Uso

@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

Anotaciones

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.

Uso desde Java (variante APT)

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.modelgenkit-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.

Modelo de salida

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 }

Colecciones — opaco por defecto, por clave si se pide

  • Sin @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.
  • Con @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 @AuditKeylines[[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).
  • Tipos 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).

Salida: renderizar y persistir

// 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())

Campos sensibles

@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.

Recursión por delegación

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>).

Igualdad de escalares

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.

Diagnósticos

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").

Rendimiento

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.)

Arquitectura

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.

Compilar desde el fuente

./gradlew build

genkit (la base compartida) se resuelve por composite build (includeBuild("../genkit")) en desarrollo, y por coordenadas publicadas en release.

Extensiones (NO en v1)

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).

About

Auditoría/diff de cambios en compile-time (Kotlin/KSP + Java/APT), zero-reflection, sobre genkit.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages