Volcar la superficie, validarla en CI y revisar su diff
Si la compatibilidad binaria no puede verificarse leyendo el código, hay que materializarla en un artefacto que sí se pueda comparar. Esta lección explica cómo volcar la superficie pública de un módulo a un fichero de texto versionado, cómo enganchar la comprobación al ciclo de verificación para que la integración continua falle ante cualquier cambio no declarado, y por qué ese diff es la pieza más informativa de toda la revisión de un cambio en una biblioteca.
La conclusión práctica de la lección anterior es demoledora para cualquier proceso basado en la atención humana: los cambios que rompen el enlazado no se distinguen visualmente de los que no lo rompen. Un revisor competente y descansado, leyendo con cuidado un cambio que añade un valor por defecto a un parámetro, no tiene ninguna razón para detenerse, porque lo que ve es una mejora aditiva. La información que le falta no está en el código fuente sino en el artefacto compilado, y no hay lectura que la recupere. La respuesta del ecosistema de Kotlin a este problema es sencilla y curiosamente clásica: materializar la superficie pública en un fichero de texto, versionarlo junto al código, y convertir cualquier divergencia entre lo que el módulo compila y lo que ese fichero declara en un fallo de construcción. Con eso, el contrato deja de vivir en la memoria de alguien y pasa a tener el mismo estatus que las pruebas.
- Generar un volcado textual de la superficie pública de un módulo y entender qué información contiene cada línea.
- Enganchar la verificación al ciclo de construcción para que la integración continua falle ante cualquier divergencia.
- Interpretar el diff del volcado como el documento central de la revisión de un cambio en una biblioteca.
- Distinguir el volcado de la plataforma Java del de bibliotecas multiplataforma y saber cuándo hacen falta ambos.
Un fichero de texto que es el contrato
La idea de fondo no es nueva ni exclusiva de Kotlin. Toda disciplina que necesita comparar dos estados de algo intangible acaba inventando el mismo recurso: producir una representación textual canónica y comparar textos, porque comparar textos es lo único que las herramientas de control de versiones y las personas saben hacer bien. Lo que Kotlin aporta es una representación fiel de lo que realmente importa, que no es el código fuente sino el conjunto de símbolos que el artefacto ofrece al cargador de clases.
La herramienta canónica es el validador de compatibilidad binaria, binary-compatibility-validator, nacido dentro de las propias bibliotecas de JetBrains para custodiar sus artefactos y publicado después como complemento de Gradle independiente. Su modelo es minimalista: compila el módulo, recorre las clases resultantes, filtra lo que no es públicamente accesible y escribe lo que queda en un fichero con extensión .api bajo un directorio api del módulo. Ese fichero se añade al control de versiones como cualquier otro fuente.
// build.gradle.kts de la raiz
plugins {
id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.18.1"
}
apiValidation {
ignoredProjects.addAll(listOf("ejemplos", "banco-de-pruebas"))
nonPublicMarkers.add("com.ejemplo.ApiInterna")
}
Desde Kotlin 2.2 el propio complemento de Gradle de Kotlin incorpora esta capacidad de forma experimental mediante un bloque abiValidation dentro de kotlin, con tareas equivalentes de actualización y comprobación, lo que evita añadir un complemento externo. Sea cual sea la vía, lo importante es el artefacto que producen, cuya forma es deliberadamente cercana a la de un descriptor de la máquina virtual y no a la de código Kotlin.
public final class com/ejemplo/red/Cliente {
public fun <init> (Ljava/lang/String;)V
public final fun conectar (Ljava/lang/String;)V
public final fun etiquetas ()Ljava/util/List;
}
Nada de eso está pensado para leerse de corrido, y ahí reside precisamente su utilidad. El fichero se lee siempre en forma de diferencia, nunca entero salvo el día en que se genera por primera vez, y esa primera lectura completa es un ejercicio que conviene no saltarse porque casi siempre depara sorpresas: constructores públicos que nadie pretendía ofrecer, métodos generados por complementos, clases auxiliares que salieron de un procesador de anotaciones y propiedades que quedaron públicas de la época anterior al modo explícito.
Esa aspereza es una virtud y no un descuido. El fichero no está escrito para ser bonito, sino para que cualquier cambio en el descriptor real produzca un cambio textual visible. Un tipo de retorno ensanchado, un parámetro añadido o una función mudada de fichero alteran la línea correspondiente de forma inequívoca, precisamente porque la representación refleja lo que ve el cargador de clases y no lo que ve el lector del fuente.
El complemento respeta además las decisiones de la primera lección de este nivel: lo marcado como internal o private no aparece, y lo anotado con marcadores que se declaren en nonPublicMarkers tampoco. Eso permite excluir del contrato paquetes de implementación que por razones técnicas deban ser públicos, un caso frecuente en bibliotecas multimódulo.
Aprender a leer el fichero cuesta una tarde y se rentabiliza durante años. Cada línea comienza por la visibilidad y la clase de elemento, sigue con el nombre completo separado por barras y termina con el descriptor entre paréntesis: primero los tipos de los parámetros y después, tras el paréntesis de cierre, el tipo de retorno. Las letras sueltas son los tipos primitivos, siendo V la ausencia de valor de retorno e I el entero, y los tipos de referencia aparecen con una L inicial y un punto y coma final. Merece la pena notar que los parámetros de tipo genérico no aparecen: para la máquina virtual, una lista de cadenas y una lista de enteros son el mismo descriptor, de modo que el volcado no detecta cambios que solo afecten a la genericidad.
Dos tareas y un ciclo
La simetría entre ambas tareas es lo que hace el sistema comprensible sin documentación: una escribe el fichero y la otra lo lee. Todo el valor procede de que la escritura sea un acto manual y la lectura sea automática, porque esa asimetría es exactamente la que obliga a que alguien decida antes de que la máquina consienta.
El flujo completo se apoya en dos tareas de Gradle con papeles opuestos. La de volcado regenera el fichero .api a partir del estado actual del código, y es la que se ejecuta deliberadamente cuando el cambio de superficie es intencionado. La de comprobación regenera la superficie en memoria, la compara con el fichero versionado y falla si difieren; el complemento la engancha a la tarea de verificación del proyecto, de modo que la construcción normal ya la ejecuta sin configuración adicional.
// Cambio de API intencionado: se regenera y se versiona el resultado
// ./gradlew apiDump
// Verificacion: forma parte de check y por tanto de la construccion normal
// ./gradlew apiCheck
tasks.named("check") {
dependsOn(tasks.named("apiCheck"))
}
Hay una regla no escrita que conviene escribir: el fichero de volcado no se edita a mano jamás. Es una salida generada, y editarlo para que la comprobación pase equivale a modificar el resultado esperado de una prueba hasta que deje de fallar. La tentación aparece de verdad, sobre todo cuando el diff es largo y el cambio parece obviamente correcto, y el antídoto es cultural más que técnico: quien revisa debe saber que un cambio en el fichero .api sin el cambio de código correspondiente es motivo automático de rechazo.
La consecuencia disciplinaria es la interesante. A partir de ese momento, ningún cambio que altere la superficie pública puede llegar a la rama principal sin que su autor haya ejecutado el volcado y haya incluido el fichero regenerado en el mismo cambio. Es decir: la modificación del contrato deja de ser un efecto colateral invisible y pasa a ser un acto explícito, registrado, atribuible y revisable. El validador no impide romper nada, y eso es importante entenderlo bien; lo que impide es romper algo sin declararlo.
flowchart TD
A[Cambio en el codigo del modulo] --> B[La construccion ejecuta la comprobacion de superficie]
B --> C{Coincide con el fichero versionado}
C -->|Si| D[Sigue el resto de la verificacion]
C -->|No| E[Fallo de construccion con el diff]
E --> F{El cambio de superficie era intencionado}
F -->|No| G[Corrige el codigo y conserva la firma]
F -->|Si| H[Ejecuta el volcado e incluye el fichero en el cambio]
H --> I[El diff entra en la revision como documento principal]Hay un detalle de configuración que decide si la práctica se sostiene o se abandona a las pocas semanas, y es el momento exacto en que la comprobación se ejecuta. Si solo corre en el servidor de integración, cada autor descubre el fallo minutos después de enviar su cambio, con el coste de contexto que eso supone; si además está enganchada a la verificación local, lo descubre antes de enviarlo, que es cuando todavía tiene el problema en la cabeza. La configuración deseable es que ambas cosas ocurran y que el mensaje de fallo diga con todas las letras qué tarea hay que ejecutar para regenerar el volcado, porque de lo contrario cada persona nueva del equipo pierde su primera media hora buscándolo.
tasks.named("apiCheck") {
doFirst {
logger.lifecycle("Si el cambio de API es intencionado, ejecuta ./gradlew apiDump")
}
}
En bibliotecas multiplataforma hay una segunda dimensión, porque la superficie relevante fuera de la máquina virtual no son descriptores de clases sino la interfaz binaria de las bibliotecas de Kotlin, con su propio formato de volcado y sus propias reglas de compatibilidad. Las herramientas actuales generan por eso dos artefactos, uno por objetivo de máquina virtual y otro para el resto de plataformas, y merece la pena revisar ambos, porque no siempre se rompen a la vez ni por los mismos motivos.
Cada exclusión que añades a la configuración es un trozo de superficie que dejas de vigilar. Los proyectos ignorados y los marcadores de no publicidad son legítimos, pero conviene revisarlos periódicamente: una biblioteca con media docena de exclusiones acumuladas ha vuelto al punto de partida sin que nadie lo haya decidido.
El diff como documento de revisión
Conviene por último decidir quién es responsable de ese fichero, porque un artefacto sin propietario acaba aceptándose por rutina. En proyectos con varias personas, la práctica que mejor aguanta es exigir en el fichero de propietarios de código una aprobación específica para el directorio del volcado, distinta de la que basta para el resto del repositorio: así, cualquier cambio de superficie convoca automáticamente a quien tiene el contexto para juzgarlo.
El cambio de hábito más rentable no es instalar el complemento, que cuesta diez minutos, sino reordenar la revisión alrededor de su resultado. En un cambio que toca una biblioteca publicada, el fichero que debe abrirse primero es el .api, antes que la implementación y antes que las pruebas, porque contiene la única parte del cambio que será irreversible. La implementación se podrá corregir mañana; las firmas publicadas no.
Ese cambio de orden tiene un efecto secundario que compensa por sí solo la instalación de la herramienta: acorta las discusiones. Un debate sobre si una función debería llamarse de una manera o de otra, o sobre si un parámetro debería aceptar un tipo más general, es interminable mientras se conduce sobre la implementación, porque cada participante argumenta desde su propia idea de lo que la biblioteca es. Conducido sobre el fichero de superficie, el mismo debate se vuelve concreto y breve, porque lo que hay delante son diez líneas que dicen exactamente qué se promete y a partir de cuándo.
La lectura del diff se organiza con tres preguntas encadenadas. La primera es si hay líneas eliminadas o modificadas, que son las que corresponden a rupturas: toda línea que desaparece es un símbolo que dejará de enlazar, y toda línea alterada es un descriptor que cambió. La segunda, ante líneas añadidas, es si cada símbolo nuevo estaba realmente destinado a ser público o se coló por inercia, que es la pregunta de la primera lección aplicada en el último momento útil. La tercera, más estratégica, es si el conjunto de la superficie sigue teniendo el tamaño que el proyecto puede sostener durante los años que se ha comprometido a sostenerlo.
De las tres, la segunda es la que más valor aporta a largo plazo y la que más se descuida, porque una línea añadida no rompe nada hoy y la revisión tiende a concentrarse en lo que duele de inmediato. Conviene invertir ese sesgo deliberadamente: una línea eliminada es un problema conocido con un procedimiento conocido, mientras que una línea añadida es una obligación silenciosa que nadie volverá a examinar nunca y que el proyecto arrastrará hasta su próxima versión mayor. El momento de discutir un símbolo nuevo es el único momento en que discutirlo es gratis.
Conviene además explicitar la política en el propio repositorio, porque una herramienta sin regla escrita acaba desactivada la primera vez que estorba. Una formulación practicable distingue tres casos: las líneas añadidas se aceptan con una revisión ordinaria; las líneas modificadas o eliminadas requieren aprobación de quien mantiene el proyecto y una nota en el registro de cambios; y ambas cosas juntas, en una versión que no sea mayor, exigen justificar por qué no se ha usado el camino de obsolescencia gradual que estudia la lección siguiente.
Hay una objeción previsible a todo esto, que es la de que la herramienta produce fricción cuando la superficie cambia mucho, y merece una respuesta franca en lugar de una defensa. La objeción es cierta y además es el efecto deseado: si el volcado cambia en la mitad de los cambios que llegan a la rama principal, lo que la fricción está midiendo no es un exceso de celo de la herramienta sino que el diseño de la biblioteca todavía no ha convergido. La respuesta correcta en ese caso no es relajar la vigilancia, sino reconocer que ese módulo aún no debería estar publicado como estable, y ofrecerlo bajo el régimen de aceptación explícita que estudia la última lección del nivel.
Queda una advertencia final sobre lo que el volcado no cubre, para no confundir custodia con garantía. El fichero registra firmas, y solo firmas: no ve el comportamiento, no ve las excepciones que una función puede lanzar, no ve la nulabilidad tal como la interpreta un consumidor de Java, no ve la genericidad borrada y no ve las promesas de la documentación. Custodia el eje binario con precisión absoluta y deja intactos los otros dos, que siguen dependiendo de las pruebas y de la revisión humana. Saber exactamente dónde acaba la cobertura de una herramienta es parte de usarla bien.
Contrato materializado
El fichero .api convierte una propiedad del bytecode en un texto que se versiona, se compara y se revisa como cualquier otro fuente.
Declarar, no impedir
La herramienta no prohíbe romper. Prohíbe romper en silencio, que es la única parte del problema que una máquina puede resolver.
Primero el diff
En una biblioteca, el orden de lectura correcto es superficie, implementación y pruebas. Lo irreversible se revisa primero.
Hay un patrón que se repite en toda la historia de la ingeniería de software y que esta lección instancia con una nitidez poco común: cada vez que una propiedad importante de un sistema depende de que una persona se acuerde de comprobarla, esa propiedad se pierde, y no se pierde por incompetencia sino por estadística, porque basta con que la atención falle una vez entre doscientas revisiones para que el fallo entre. La compatibilidad binaria es el ejemplo perfecto porque reúne las tres condiciones que garantizan el desastre: es invisible en el código fuente, es catastrófica cuando falla y su fallo aparece lejos en el tiempo y lejos en el espacio de quien lo introdujo, en la máquina de un tercero que ni siquiera pidió la actualización. Ninguna cantidad de seniority resuelve eso. Lo que lo resuelve es exactamente lo que hace el volcado: convertir una propiedad no observable en un artefacto observable, y después colocar ese artefacto en el único lugar donde las cosas se comprueban siempre, que es la construcción automática. Conviene ver hasta dónde llega el principio, porque no acaba aquí. El volcado de superficie es a la compatibilidad lo que las pruebas de regresión son al comportamiento, lo que el fichero de bloqueo de dependencias es a la reproducibilidad, lo que el formateador automático es al estilo y lo que el sistema de tipos es a la corrección local: en los cinco casos se toma una promesa que hasta entonces flotaba en la cultura del equipo y se le da un cuerpo que una máquina puede examinar. La consecuencia organizativa es más profunda de lo que parece, porque cambia la naturaleza de la conversación en una revisión de código. Sin volcado, el revisor discute intenciones y confía; con volcado, el revisor discute un texto concreto que dice exactamente qué se promete a partir de hoy y para siempre. Y esa diferencia, la de sustituir la confianza por la evidencia en el punto exacto donde el error sería irreversible, es probablemente la definición más útil que existe de madurez en un proyecto de software.
- Añade el validador a un módulo tuyo, ejecuta el volcado y lee el fichero resultante entero sin saltarte líneas.
- Busca en ese fichero al menos tres símbolos cuya presencia te sorprenda y decide si los ocultas o los asumes.
- Provoca a propósito los cuatro cambios rompedores de la lección anterior y observa exactamente qué líneas del volcado cambia cada uno.
- Engancha la comprobación a la construcción y verifica que un cambio de superficie sin volcado regenerado detiene la integración continua.
- Escribe en el repositorio la política de revisión del diff en tres reglas y sométela a quien mantiene el proyecto contigo.