Generar diagramas: documentación derivada y verificada
Cierre del nivel: llevar el diagrama a la documentación sin reintroducir la deriva. Se comparan los tres regímenes de sincronización posibles, se construye un generador de Mermaid a partir de `toDirectedGraph` con salida estable para que la verificación en integración continua no produzca falsos positivos, se analiza qué del statechart no sobrevive a la traducción y cómo trocear máquinas grandes en vistas legibles, y se cubren los destinos de publicación, desde el renderizado en tiempo de construcción hasta el SVG estático para documentos externos.
La primera lección sostuvo que un diagrama solo merece confianza si es una proyección de la fuente y no una copia suya. Queda la parte de ingeniería que hace verdadera esa afirmación fuera del estudio: en el archivo de instrucciones del repositorio, en el portal de documentación interna, en el documento que alguien exporta a PDF para un comité. Esos destinos no ejecutan tu aplicación ni saben nada de XState, así que la proyección hay que fabricarla, versionarla o verificarla, y cada una de esas tres opciones tiene un coste y un fallo característico. Lo que sigue es el trabajo poco glamuroso que separa la promesa de la documentación viva de su realidad: un generador determinista, una comprobación en integración continua y un criterio de granularidad, porque un diagrama exacto de cuarenta estados no es documentación, es una madeja.
- Elegir entre derivar el diagrama en tiempo de construcción, generarlo y versionarlo, o exportarlo a mano.
- Construir un generador de Mermaid a partir de
toDirectedGraphcon salida estable y verificable. - Reconocer qué elementos del statechart no sobreviven a la traducción y decidir cómo anotarlos.
- Trocear máquinas grandes en vistas legibles y publicarlas en web, en SVG y en documentos externos.
Tres regímenes de sincronización
Antes de comparar conviene fijar el criterio de evaluación, porque no es la fidelidad del dibujo. Un régimen de sincronización se juzga por una sola propiedad: qué ocurre cuando alguien cambia la máquina y no piensa en la documentación, que es el caso normal y no la excepción. Todo lo demás —lo bonito que quede el diagrama, lo cómoda que sea la herramienta— es secundario frente a esa pregunta, porque la documentación viva no se pierde en los momentos de atención sino en los de prisa.
Solo hay tres formas de que un diagrama llegue a la documentación, y elegir mal condena el esfuerzo desde el principio. La primera es derivar en tiempo de construcción: el generador de la documentación importa la máquina y produce el diagrama en cada compilación, de modo que la deriva es imposible por construcción. La segunda es generar y versionar: un script escribe un archivo de diagrama en el repositorio, ese archivo se revisa como cualquier otro código y la integración continua falla si alguien cambió la máquina sin regenerarlo. La tercera es exportar a mano desde el estudio y pegar una imagen, que es la que todo el mundo hace primero y la única que garantiza la deriva.
Derivar al construir
Deriva imposible, cero mantenimiento. Exige que el sitio de documentación pueda importar el código de la máquina, cosa que Astro o cualquier empaquetador moderno permite.
Generar y verificar
El diagrama vive en el repositorio y la integración continua comprueba que está al día. Funciona con cualquier destino, incluso con los que no ejecutan tu código.
Exportar a mano
Aceptable para una presentación o un artículo con fecha. Inaceptable para documentación viva, porque su corrección depende de que alguien se acuerde.
La elección entre los dos primeros regímenes la decide casi siempre un dato prosaico: dónde vive la documentación. Si vive en el mismo repositorio y se construye con las mismas herramientas que la aplicación, derivar en tiempo de construcción es lo más limpio y hay que hacerlo. Si vive en un portal corporativo, en un espacio de páginas compartidas o en cualquier sitio que no ejecute tu código, el segundo régimen es la única opción honesta, porque produce un artefacto que se puede subir a cualquier parte y que además deja un rastro de si estaba al día cuando se subió. La regla, entonces, no es de gusto: deriva si puedes, genera y verifica si no.
El segundo régimen tiene una virtud que se suele pasar por alto y que por sí sola justifica adoptarlo: al estar el diagrama versionado, la diferencia del archivo generado es la diferencia del comportamiento. En una revisión de cambios, quien revisa no tiene que reconstruir mentalmente qué implica mover una transición dentro de un objeto anidado de cincuenta líneas; ve aparecer y desaparecer líneas de la forma origen, flecha, destino, evento. Es la única técnica conocida que convierte un cambio de comportamiento en un artefacto de revisión legible por alguien que no escribió el código, y en equipos con personas de producto que leen las peticiones de cambio, cambia la calidad de la conversación.
flowchart LR A[Definicion de la maquina] --> B[Generador determinista] B --> C[Archivo mmd versionado] C --> D[Render en el sitio de documentacion] C --> E[SVG estatico para PDF o intranet] C --> F[Verificacion en integracion continua] F -->|falla si hay deriva| G[La revision se bloquea]
De la definición a Mermaid
El paquete @xstate/graph expone toDirectedGraph, que convierte una máquina en un grafo dirigido de nodos y aristas recorrible con una función recursiva. Traducirlo a la sintaxis de diagrama de estados de Mermaid son unas veinte líneas, con dos detalles que no son opcionales: sanear los identificadores, porque XState los compone con puntos y Mermaid no los admite, y ordenar la salida, porque sin un orden estable cada regeneración produce una diferencia falsa y la verificación en integración continua se vuelve ruido que el equipo aprende a ignorar.
import { toDirectedGraph } from '@xstate/graph'
import type { AnyStateMachine } from 'xstate'
// Mermaid no admite puntos en los identificadores de estado.
const sano = (s: string) => s.replace(/\./g, '_')
export function aMermaid(maquina: AnyStateMachine): string {
const raiz = toDirectedGraph(maquina)
const lineas = new Set<string>()
const visitar = (nodo: typeof raiz) => {
for (const arista of nodo.edges) {
// Los nombres exactos de los campos dependen de la version del paquete:
// consulta los tipos antes de fijar este mapeo.
const evento = arista.label?.text || 'automatica'
lineas.add(` ${sano(arista.source.id)} --> ${sano(arista.target.id)} : ${evento}`)
}
for (const hijo of nodo.children) visitar(hijo)
}
visitar(raiz)
// Orden estable: sin esto, cada generacion produce una diferencia falsa.
return ['stateDiagram-v2', ...[...lineas].sort()].join('\n')
}
La deduplicación con un conjunto no es un detalle de eficiencia sino de corrección visual: dos transiciones distintas entre el mismo par de estados con el mismo evento y guardas diferentes producirían dos flechas idénticas superpuestas, que en el diagrama se leen como una sola y ocultan que hay una decisión de por medio. O se incorpora la guarda a la etiqueta, y entonces las flechas son legítimamente distintas, o se colapsan en una y se acepta que el diagrama no muestra la ramificación. Lo que no se puede es dibujar dos veces lo mismo y creer que el diagrama es exacto.
Con el generador escrito, la verificación es un script de una decena de líneas que se ejecuta en la integración continua. La técnica es la misma que la de las pruebas de instantánea o la de los archivos generados de un compilador: se regenera y se compara con lo versionado, y si difieren se falla con un mensaje que diga exactamente qué comando arregla la situación. Un mensaje de fallo que no incluye el comando de reparación convierte una salvaguarda en un obstáculo.
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { aMermaid } from './a-mermaid'
import { pago } from '../src/maquinas/pago'
const destino = 'docs/maquinas/pago.mmd'
const nuevo = aMermaid(pago)
const viejo = existsSync(destino) ? readFileSync(destino, 'utf8') : ''
if (process.env.CI && nuevo !== viejo) {
console.error('docs/maquinas/pago.mmd no coincide con la maquina.')
console.error('Ejecuta: npm run diagramas')
process.exit(1)
}
writeFileSync(destino, nuevo)
Dos afinados elevan mucho la calidad de la salida sin complicar el generador. El primero es acortar los identificadores: el grafo entrega rutas completas como pago.cobrando.reintentando, y usarlas tal cual produce cajas larguísimas; conviene mostrar solo el último segmento como etiqueta y reservar la ruta completa para el identificador interno del nodo. El segundo es marcar los estados terminales, que Mermaid representa con la transición al nodo especial de fin, y el inicial con la transición desde el nodo especial de comienzo: sin esas dos marcas, el lector no sabe por dónde entra ni dónde acaba el proceso, que suele ser lo primero que quiere saber.
En un sitio hecho con Astro cabe además el primer régimen, y es el más cómodo de todos: como el bloque inicial de una página se evalúa en el servidor durante la construcción, se importa la máquina, se llama al generador y se entrega el texto resultante al componente que renderiza Mermaid a través de su prop code. No hay archivo intermedio, no hay verificación que mantener y la página no puede mostrar un diagrama distinto del que ejecuta la aplicación, porque los dos salen del mismo módulo.
El mismo recorrido del grafo que produce el diagrama produce una tabla de origen, evento, guarda y destino, y esa tabla es superior al diagrama en tres situaciones concretas: se puede buscar con las herramientas de texto del repositorio, la leen bien los lectores de pantalla y sigue siendo legible cuando la máquina tiene cuarenta estados y el dibujo ya no lo es. Publicar ambas cuesta unas pocas líneas más y cubre públicos que el diagrama solo deja fuera.
Lo que no sobrevive a la traducción
Mermaid tiene una sintaxis de diagrama de estados razonablemente completa —estados compuestos, estados inicial y final, regiones concurrentes separadas por una línea doble, y estereotipos de elección, bifurcación y unión—, pero no cubre el statechart entero y conviene saber dónde están los bordes antes de descubrirlos con un diagrama engañoso. Las guardas no tienen sintaxis propia, así que hay que concatenarlas a la etiqueta del evento entre corchetes, siguiendo la convención de UML. Las transiciones retardadas aparecen como eventos internos con nombres poco amables y merecen una traducción legible del estilo tras diez segundos. Los estados de historia no existen en la notación y hay que representarlos con una nota. Y nada de lo que rodea a los actores invocados —qué se invoca, con qué entrada, qué devuelve— tiene expresión en un diagrama de estados.
La tentación al escribir un generador es representarlo todo, y produce diagramas que nadie mira. Un modelo con cuarenta estados y ciento veinte transiciones renderizado de una vez es una madeja de la que no se extrae ninguna información, por más que sea rigurosamente correcta. La documentación tiene un propósito y el diagrama debe elegir uno: el mapa general se dibuja con los estados compuestos colapsados y sin guardas, y cada región interesante recibe su propio diagrama de detalle generado desde ese subárbol. La restricción clásica de siete elementos más o menos dos no es una superstición de la psicología cognitiva, es el límite real por encima del cual el lector deja de seguir flechas.
El criterio para decidir qué se anota y qué se omite es el propósito del diagrama, y conviene escribirlo antes de programar el generador. Un diagrama para incorporar a alguien nuevo al equipo quiere pocos elementos y nombres claros; uno para auditar un flujo regulado quiere todas las guardas y todas las transiciones de caducidad, aunque resulte denso; uno para revisar un cambio quiere ser estable entre versiones para que la diferencia sea legible. Son tres generadores con parámetros distintos sobre la misma fuente, no un generador perfecto que sirva para todo, y aceptarlo ahorra mucho tiempo perdido buscando ese generador perfecto.
Trocear es, por tanto, parte del trabajo del generador y no una decisión de última hora. La estructura jerárquica de la máquina ya ofrece las costuras naturales: cada estado compuesto es un candidato a diagrama propio, con su padre reducido a una caja en el mapa general. La regla práctica que funciona es publicar un diagrama de nivel superior sin guardas ni acciones, uno por región compuesta con todo el detalle, y la tabla de transiciones completa como referencia exhaustiva. Tres artefactos derivados de una fuente, cada uno con un lector distinto en la cabeza.
Publicar dentro y fuera del sitio
Antes de elegir destino conviene resolver una cuestión de ubicación que decide si el diagrama se lee o no: la documentación derivada solo se consulta si está donde ya se mira. Un diagrama excelente en un portal que nadie abre vale menos que uno mediocre en el archivo de instrucciones que está junto al código de la máquina. La colocación por defecto que funciona es dejar el diagrama y la tabla en la misma carpeta que la máquina, y que el sitio de documentación los incluya desde ahí; así quien llega por el código lo encuentra sin salir del editor, y quien llega por el portal lo encuentra igualmente.
Dentro de un sitio propio, lo natural es renderizar el texto de Mermaid en la página, que además mantiene el diagrama como texto seleccionable y buscable. Fuera —una intranet, un documento para un comité, un PDF de auditoría— hace falta una imagen, y la vía reproducible es la herramienta de línea de comandos de Mermaid ejecutada en la integración continua, que convierte cada archivo de diagrama en SVG con el mismo tema en cada ejecución. Al SVG generado conviene añadirle un título y una descripción textual: sin ellos es una imagen muda para quien usa un lector de pantalla, y también para el buscador del portal.
Un efecto lateral del segundo régimen que solo se aprecia con el tiempo: como el archivo de diagrama está versionado, el historial del repositorio guarda la evolución del comportamiento del sistema, y no solo la de su código. Preguntar cuándo dejó de existir la transición de fallido a cobrando, o en qué versión apareció el estado de caducidad, se responde con una consulta al historial del archivo generado en lugar de con una arqueología de diferencias de TypeScript. Para sistemas con requisitos de auditoría —pagos, historiales clínicos, cualquier flujo regulado— esa capacidad no es una comodidad, es la prueba documental de qué hacía el sistema en una fecha determinada.
Queda una cuarta vía que merece mención y una advertencia: incrustar el editor del estudio en la página mediante un marco. La fidelidad es máxima, se obtiene la simulación interactiva gratis y no hay generador que mantener; a cambio se introduce una dependencia de un servicio externo en la documentación, que deja de funcionar sin conexión, deja de funcionar si el servicio cambia y expone el modelo fuera de la infraestructura propia. Es una buena elección para una guía de aprendizaje interna y una mala para la documentación de un sistema que alguien tendrá que entender dentro de cinco años.
Todo el nivel converge en una idea que va más allá de los diagramas y que conviene enunciar sin rodeos: cuando el comportamiento tiene una representación derivable, la unidad de revisión del equipo deja de ser el código y pasa a ser el comportamiento. Es un cambio de nivel de abstracción del proceso, no de la herramienta. Hoy, en la inmensa mayoría de los equipos, revisar un cambio consiste en leer diferencias de texto e intentar reconstruir mentalmente qué implica cada una para lo que el sistema hace; es un trabajo de simulación humana, agotador, propenso al error y que solo pueden hacer quienes conocen ya el archivo. Cuando el diagrama y la tabla de transiciones están generados y versionados, la petición de cambio muestra dos capas: la diferencia del código, para quien la necesite, y la diferencia del comportamiento —esta transición ya no existe, este estado es nuevo, este camino ya no lleva a donde llevaba—, legible por cualquiera. La consecuencia es que la conversación sobre si el cambio es correcto puede ocurrir con las personas correctas, incluidas las que no leen TypeScript, y puede ocurrir antes de fusionar en lugar de después de desplegar. Y hay un corolario que se olvida con facilidad: esto solo es posible porque en algún momento alguien decidió que el comportamiento fuera un dato en lugar de un montón de instrucciones repartidas. La documentación derivada no es una técnica que se pueda aplicar a cualquier código; es un privilegio que se gana modelando, y es probablemente la mejor respuesta que existe a la pregunta de qué te devuelve realmente el coste de escribir una máquina de estados en lugar de cuatro banderas booleanas. Te devuelve un sistema que sabe explicarse a quien no lo escribió, y esa es, al final, la propiedad de la que dependen todas las demás.
- Escribe el generador de Mermaid para una máquina real de tu proyecto y comprueba que dos ejecuciones seguidas producen exactamente el mismo texto.
- Añade las guardas a las etiquetas entre corchetes y traduce las transiciones retardadas a una expresión legible. Comprueba que el diagrama sigue renderizando.
- Versiona el archivo generado y añade el script de verificación a tu integración continua, con un mensaje de fallo que incluya el comando de reparación.
- Cambia una transición de la máquina sin regenerar y confirma que la comprobación falla. Después regenera y observa la diferencia del archivo: eso es el cambio de comportamiento hecho visible.
- Trocea la máquina en un mapa general con los estados compuestos colapsados y un diagrama de detalle por región, y compara la legibilidad con la del diagrama único.
- Genera además la tabla de transiciones, publícala junto al diagrama y verifica que puedes encontrar un estado concreto buscando texto en el repositorio.