Toda costura escrita a mano necesita una guarda. Crossweft, una comprobación de contratos entre lenguajes para agentes de IA
Autora: Anastasiia Butova, ingeniera de ComfyUI y modelos de difusión, Belgrado · publicado 2026-10-08
Traducción de la versión en inglés: English
Nuestros errores más caros nunca estuvieron dentro de un archivo. Estuvieron entre archivos. Un valor estaba escrito en dos o tres sitios, en dos lenguajes; alguien cambiaba uno de ellos, todo seguía compilando, las pruebas estaban en verde, y la instalación se rompía en la máquina de un cliente. Así es como hicimos esos sitios explícitos y comprobables, y cómo incorporamos la comprobación a los agentes de IA que escriben una parte grande de nuestro código. La herramienta es de código abierto: github.com/AnastasiyaW/crossweft, página del proyecto: happyin.work/crossweft.
Diez fronteras que el compilador no ve
Construimos un plugin de retoque para Photoshop que ejecuta una red neuronal en la propia máquina del usuario. Entre el fotógrafo y el modelo hay diez fronteras de confianza: un canal protegido entre el plugin y un servicio del sistema, el registro del dispositivo, artefactos firmados con sellos de tiempo de confianza, atestación de la compilación, el canal de actualizaciones, el cifrado del modelo y una marca de agua. Las partes están escritas en C++ y en Go, se ejecutan en procesos distintos y llegan por instaladores distintos.
Ninguna de esas fronteras es un módulo. Cada una es un acuerdo entre dos partes: el mismo nombre de servicio, la misma versión de protocolo, el mismo conjunto de rutas, la misma cadena de confirmación. Hoy 54 enlaces entre esas fronteras llevan 188 comparaciones de valores (468 lugares en el código) y 105 comparaciones de conjuntos. El compilador no ve ninguna, porque los dos lados están en lenguajes distintos.
Empezamos a llamar costuras a esos lugares: puntos donde dos componentes tienen que coincidir exactamente.
- la ruta que un cliente llama y la ruta que el servidor registra;
- una cabecera con la versión del protocolo;
- los campos de un DTO en Go y en TypeScript;
- las variables de entorno que lee un servicio y las que aparecen en
.env.example; - una regla de redondeo de precios escrita dos veces, en dos lenguajes.
Los tipos, las pruebas y los linters funcionan bien dentro de un solo lenguaje. Rara vez comprueban acuerdos así, y por eso esos acuerdos viven en la memoria de las personas.
El nombre viene de la trama, el hilo que cruza la urdimbre y mantiene unida la tela.
Tres historias
Un identificador, tres copias. El nombre del servicio se resolvía en tres funciones casi idénticas. En una máquina limpia el servicio todavía no estaba instalado, la búsqueda fallaba, una máquina vacía parecía una instalación antigua incompatible, y el instalador se detenía con el código de salida 76. Arreglamos una copia. Las otras dos siguieron rotas.
Dos mitades de una misma dependencia. La compilación tomaba una biblioteca de un directorio de instalación y sus herramientas de otro. La configuración solo imprimía una advertencia, así que el problema salía a la luz mucho después, donde la causa real era difícil de encontrar.
Una guarda que no guardaba nada. Un validador de una de nuestras reglas de seguridad tenía una ruta equivocada. Durante semanas comprobó cero archivos y dio OK siempre. Pasaba precisamente porque no se ejecutaba.
La causa es la misma en las tres: un acuerdo tiene dos o tres partes, y que coincidan depende de que alguien esté atento.
Por qué los agentes de IA empeoran esto, y cómo ayudan
Una parte grande de nuestro código la escriben agentes, sobre todo Claude Code y Codex. Manejan peor los acuerdos entre componentes que las personas. El caso típico: el agente cambia una estructura de Go, ejecuta las pruebas, ve verde y dice "hecho", sin abrir nunca la interfaz de TypeScript del otro lado.
- Ediciones locales. El otro lado de la costura está en otra carpeta, otro lenguaje, otro proceso.
- Contexto limitado. El agente no tiene todo el producto en la cabeza, y el compilador no le dirá que un cliente en algún sitio depende de esa estructura.
- Una idea estrecha de "terminado". Para un agente, terminado significa "todas las comprobaciones que veo han pasado". Si ninguna comprobación mira la costura, la deriva queda invisible hasta que algo se rompe.
Los agentes siguen bien las instrucciones locales y precisas. Dile a un agente, justo después de la edición, qué archivo del otro lado tiene que abrir y qué debe coincidir allí, y lo hará. No lo dejes terminar mientras los dos lados discrepan, y el error nunca llega a un commit.
La regla: cada enlace dice cómo se mantiene de acuerdo
Cada enlace entre componentes declara cómo se mantienen de acuerdo sus dos partes. Si ambas están escritas a mano, el enlace necesita una guarda: una comprobación que lea los dos lados.
Los enlaces viven en archivos JSON junto al código. El mapa tiene bloques (programas, servicios, almacenes, archivos de configuración) y enlaces entre ellos (HTTPS, una tubería con nombre, archivos, generación de código, variables de entorno). Cada afirmación del mapa está anclada a un literal de un archivo fuente, así que cuando el código se mueve, el mapa no puede seguir describiendo el pasado en silencio. La comprobación falla.
Cada enlace tiene un campo contract.enforcement:
| Enforcement | Quién mantiene iguales los lados | Lo que exige la comprobación |
|---|---|---|
shared-code |
un símbolo que ambos lados importan | defined_in nombra el símbolo |
generated |
un generador a partir de una fuente | defined_in nombra la fuente |
schema-tests |
vectores de prueba compartidos | defined_in nombra los vectores |
duplicated |
nadie, se copia a mano | una guarda que lea un archivo de cada lado |
convention |
nadie, está acordado | una guarda que lea un archivo de cada lado |
none |
nadie | una guarda que lea un archivo de cada lado |
La clasificación por sí sola es útil. "¿Dónde seguimos confiando en la memoria de alguien?" se convierte en una lista que se puede contar. Una guarda que lee solo un lado no cuenta: la herramienta comprueba que lee archivos en dos extremos distintos del enlace.
Tres guardas: join, set y pair
Las guardas son deliberadamente simples: expresiones regulares, comparación de valores o de conjuntos de valores. Una comprobación tarda milisegundos, no necesita compilar y funciona con cualquier lenguaje.
join: el valor es el mismo en todas partes. La versión de API que envía el cliente TypeScript y la que espera el servidor Go:
{"id": "api-version", "link": "web-orders", "points": [
{"path": "web/src/api.ts", "side": "web", "regex": "API_VERSION = \"([^\"]+)\""},
{"path": "server/main.go", "side": "api", "regex": "APIVersion = \"([^\"]+)\""}]}
Se compara cada aparición, no solo la primera. Nuestra primera versión solo comprobaba la primera coincidencia, así que una segunda copia más abajo en un archivo podía colarse.
set: los miembros coinciden. Los campos de una interfaz de TypeScript frente a las etiquetas JSON de Go; los estados que escribe un worker de Python frente a las constantes de Go; las variables de entorno que lee el código frente a .env.example. Una diferencia intencionada va a allow con un motivo, y una entrada de allow que ya no hace falta hace fallar la propia comprobación.
pair: lógica duplicada sin un único valor que comparar. Por ejemplo, el redondeo de precios en Go y en TypeScript. Ambas regiones están marcadas con crossweft:begin price-rounding y crossweft:end price-rounding, y Crossweft calcula una huella de cada una. Si cambia cualquiera, la comprobación falla hasta que alguien vuelva a leer la copia gemela y ejecute crossweft attest price-rounding --reason "...". El motivo entra en el archivo de bloqueo y en la revisión.
La salida más corta de un pair en rojo, para un agente, es certificar el lado que cambió y no tocar nunca el otro. Así que si solo una región ha cambiado desde la última atestación, attest se niega. Corrige primero la copia gemela o, si de verdad ya es equivalente, dilo explícitamente con --other-side-unchanged.
Hay comprobaciones más grandes también. Cada ruta que registra el servidor tiene que estar en el mapa; las rutas de Go chi se leen de forma nativa (también Express y Fastify, FastAPI, Flask, gin y echo), cualquier otra mediante una expresión regular. Cada llamada /v1/... en el código del cliente tiene que estar explicada por un enlace. Un bloque solo puede enviar datos que haya creado o recibido. Cada carpeta de código fuente tiene que estar en el mapa.
Cuando los tipos integrados no bastan, un repositorio añade los suyos como plugins. Tenemos 13. Uno compara 916 lugares donde el servidor Go habla con SQL con 161 tablas y 276 disparadores del esquema; otro comprueba 185 rutas del servidor; un tercero comprueba 27 pares "waiter ≥ work + margin" de timeouts. Un plugin que falla al importarse, se cae o no compara nada es un error, nunca un resultado en verde.
Lo que ve el agente
La comprobación es la mitad. El agente necesita enterarse de una costura justo después de la edición, no cuando el código está listo para confirmarse. Tres hooks lo hacen:
- Inicio de sesión: una nota breve de que el repositorio tiene un mapa de costuras, cuántas hay y cómo trabajar con ellas.
- Después de cada edición: si el archivo está en una costura, el agente recibe el otro lado y la guarda.
- Antes de terminar: se ejecuta
crossweft check. Si una costura no coincide, el agente no puede terminar y se le dice qué hacer.
Tras una edición en el servidor Go de la demo, el agente ve:
crossweft: server/main.go is part of 3 cross-component seam(s). Keep both sides in agreement:
- link web-orders (web -> api, https; Orders API [duplicated]): you changed its to side.
Re-read: web/src/api.ts. Guarded by: join:api-version, join:version-header,
set:order-fields, set:web-statuses, pair:price-rounding.
...
Run `crossweft check` before you finish.
Si sube la versión de la API en Go y trata de parar, el hook lo devuelve:
crossweft check fails:
- join:api-version disagrees: web@web/src/api.ts:3='2026-09-01'; api@server/main.go:14='2026-10-01'
[server/main.go:14] (key: join:api-version:b5dfb7e5)
A seam is out of agreement: bring the other side in line with the one you changed
(`crossweft impact <file>` names it).
El consejo depende del tipo de problema. Si un literal al que apunta el mapa ha desaparecido, al agente se le dice que actualice el ancla o restaure el código, no que "arregle el otro lado". La clave termina en un resumen de qué archivo contiene qué valor, así que una excepción registrada solo excusa ese desacuerdo exacto.
Un hook de parada tiene dos modos clásicos de fallo: un bucle sin fin y un paso silencioso. El nuestro devuelve al agente solo mientras progrese (el conjunto de claves que fallan cambia), como máximo cuatro veces seguidas. Sin progreso, el agente puede parar, y la persona recibe un mensaje explícito de que la comprobación sigue en rojo.
Para Claude Code hay un plugin con hooks y una skill. Para Codex hay un plugin de skill. Cualquier agente que hable MCP puede conectar crossweft mcp y obtener la comprobación, el análisis de impacto de cambios y "qué archivo volver a leer" como herramientas de solo lectura. Los adaptadores de hook para Gemini CLI, Copilot y Cursor todavía son experimentales: su formato de salida está probado, el bucle completo todavía no.
Comprobaciones que no pueden pasar en silencio
La tercera historia fue un validador que durante semanas no escaneó nada. No todas las reglas son "A es igual a B"; a veces hay que demostrar que una constante tiene una sola fuente. Las reglas así viven en pequeños scripts, y son precisamente las que se rompen en silencio.
El ejecutor crossweft validators hace fallar a un validador que termina con código distinto de cero, que no imprime SCANNED: files=<n> items=<n> o imprime ceros, que no tiene autoprueba o la falla (SELF-TEST: checks=<n>, ejecutada contra un error plantado), o que se queda sin tiempo. Un validador que de verdad no aplica imprime APPLICABILITY: <reason> y recibe SKIP, no PASS.
Crossweft en sí funciona igual. Hay tres resultados, no dos: 0 significa que todo coincide, 1 significa deriva, 2 significa que la comprobación no se hizo (el modelo no se puede leer, no se escaneó nada). Un código de salida 2 nunca parece un éxito.
Un registro de deriva conocida que no se pudre
No todos los desacuerdos pueden arreglarse en el mismo pull request. Entonces registras un hallazgo: la clave del problema, un responsable y un siguiente paso. Mientras el problema exista, la comprobación pasa. Cuando se arregla, el hallazgo caduca y hace fallar la comprobación hasta que alguien lo cierre. Las anclas rotas no se pueden excusar así: el mapa no puede describir mal el código.
En un repositorio que ya tiene deriva, crossweft baseline registra toda ella como hallazgos abiertos con un responsable. La comprobación se pone en verde, la deriva nueva sigue haciéndola fallar, y lo que se arregla pide cerrarse.
Lo que cambió
Un contexto honesto primero. Desde mediados de julio, nuestro instalador nunca logró levantar el servicio del sistema en una máquina virtual limpia. Añadimos costuras comprobadas entre el 26 y el 28 de septiembre, y el 29 de septiembre el servicio arrancó por primera vez. Es una coincidencia en el tiempo, no una prueba de causa: otras correcciones entraron a la vez.
Lo que sí cambió fue el tipo de fallo. Tres de los primeros fallos del 28 de septiembre fueron costuras: el código de salida 76 (el nombre del servicio en tres copias); el servidor rechazando un cargador que se registraba con la versión del artefacto pero se pedía con la versión del protocolo; y un HTTP 500 porque dos rutas de publicación eliminaban el paquete antiguo y la tercera no. Cada una tiene ahora su propia guarda. Los dos problemas siguientes fueron errores normales, no "un valor en varios sitios".
Lo que muestra el mapa, recontado a partir del historial del repositorio:
| 27 sep | 29 sep | 4 oct | |
|---|---|---|---|
| Bloques / enlaces | 130 / 160 | 134 / 166 | 138 / 192 |
| guardas join | 10 | 220 (529 puntos) | 261 (644 puntos) |
| guardas set | 4 | 125 | 138 |
| Hallazgos | 58, todos abiertos | 106: 93 cerrados, 10 abiertos, 3 aceptados | 146: 117 cerrados, 25 abiertos, 4 aceptados |
La primera pasada de "cada enlace declara su enforcement" la hicieron los propios agentes. En un día añadieron 185 guardas join y 107 set, y encontraron 15 desacuerdos reales en el código. De los 93 hallazgos cerrados, 91 tienen un commit de corrección, y 58 de esos mensajes de commit nombran el id del hallazgo en el mapa.
Un mapa sin comprobación caduca en una semana. Una semana después de la primera medición había 96 problemas, y 94 eran el mapa desactualizado, no errores del código. Por eso el mapa se comprueba cada vez que un agente se detiene. Pasamos de 4 validadores en junio a 50, y cada uno tiene que demostrar que escaneó algo.
Abrirlo, y lo que encontró la primera ejecución de CI
La herramienta creció dentro de un producto comercial, así que la abrimos con cuidado. Hay dos ramas: el oss público, que es solo la herramienta, y un internal privado, que es oss más una carpeta con nuestras propias reglas. Los cambios fluyen de oss a internal solamente. Lo que se publica es una instantánea del árbol, un commit por versión con una firma neutra, no nuestro historial. Antes de publicar, un escáner comprueba la instantánea y los metadatos de sus commits contra una lista de palabras privadas, y un revisor independiente nuevo lee el diff desde la última versión, porque un escáner solo conoce las palabras que se le dieron. La primera revisión encontró tres frases en la documentación que insinuaban cómo está construido nuestro producto, y las reemplazamos antes de publicar.
Una etiqueta de versión solo se pone cuando CI está en verde en Linux, Windows y macOS, porque las etiquetas son inmutables. La primera ejecución fue roja. Detectó dos errores reales que 157 pruebas locales no habían visto:
- el código usaba un argumento que solo existe desde Python 3.10, mientras prometemos 3.9;
- un plugin editado dos veces en menos de un segundo, con la misma longitud, se ejecutaba desde bytecode obsoleto, porque la caché de Python distingue archivos por segundos y tamaño. Nosotros no lo veíamos en local: la escritura de bytecode está desactivada en toda la máquina de desarrollo.
Los corregimos, la segunda ejecución quedó en verde, y salió v0.1.0. Dos días después, v0.2.0 añadió el servidor MCP, check --changed, guardas construidas a partir de esquemas OpenAPI y proto, y escáneres de rutas para cinco frameworks más. Muestra el punto de este artículo: un check en verde solo te habla de los tipos de error que alguien ya ha pensado.
¿Esto no existe ya?
Cada pieza existe en algún sitio. Lo que no encontramos es la combinación, y la regla de que cada enlace declare cómo se mantiene de acuerdo y que cada costura escrita a mano tenga una guarda.
| Herramienta | Qué hace | En qué se diferencia |
|---|---|---|
LINT.IfChange de Google, ifttt-lint, if-changed |
"si cambia este bloque, cambia también aquel archivo" | comprueba que el diff tocó el archivo; un join compara valores en cada ejecución |
| clevis | un valor igual en varios archivos | sin conjuntos, sin regiones, sin mapa |
| Pact, buf, oasdiff | pruebas de contrato y compatibilidad de esquemas | la respuesta correcta cuando existe un esquema; Crossweft construye guardas desde OpenAPI y proto para el lado escrito a mano |
| ArchUnit, dependency-cruiser, import-linter | reglas de arquitectura | dentro de un solo lenguaje |
| archagent | invariantes de arquitectura y skills para agentes | el más parecido en espíritu, pero estructura en lugar de valores entre lenguajes |
| GitNexus, codegraph | grafos de código e impacto de cambios para agentes, sobre MCP | un grafo que consultas, no una comprobación que falla; se complementan |
| fiberplane/drift, Swimm | documentación anclada al código | un hash falla con cualquier edición; un ancla falla solo cuando el hecho desaparece |
El enfoque tampoco es nuestro. Birgitta Böckeler escribe sobre harness engineering: guías que actúan antes de que el agente escriba código y sensores que comprueban después, comprobaciones deterministas frente a comprobaciones hechas por un modelo de lenguaje. OpenAI escribe sobre lo mismo en Harness engineering: leveraging Codex in an agent-first world. Crossweft es un sensor determinista para costuras entre componentes cuya salida le dice al agente exactamente qué arreglar.
Límites
- Expresiones regulares, no análisis sintáctico. Si reformateas una declaración, la comprobación falla con claridad; actualizas el patrón. Pero un patrón demasiado amplio todavía puede pasar por alto un desacuerdo real, así que mira los valores extraídos y prueba el comportamiento en ejecución con pruebas normales.
- Una persona sigue comprobando que el enforcement sea honesto. Los agentes construyen el mapa, y Crossweft no les deja inventar un enlace ni saltarse una carpeta, pero si un enlace es realmente
shared-codeo dos copias a veces solo lo sabe un desarrollador. - La adopción requiere trabajo. En un proyecto pequeño la ganancia es pequeña. Compensa donde hay muchos lenguajes y procesos.
- Hasta ahora, somos los únicos usuarios. El bucle con Claude Code está probado de extremo a extremo; otros agentes se conectan por MCP y por la skill, y sus adaptadores de hook son experimentales.
check --changedacelera el pre-commit, pero todavía no cuando la edición toca un router o un archivo del cliente.
Pruébalo
La forma más rápida de ver el mecanismo es romper la demo (una API en Go, un cliente en TypeScript, un worker en Python):
pip install "git+https://github.com/AnastasiyaW/[email protected]"
git clone --branch v0.2.8 --depth 1 https://github.com/AnastasiyaW/crossweft
cd crossweft/examples/polyglot-shop && crossweft check # RESULT: PASS
# now change API_VERSION in web/src/api.ts only:
crossweft check # join:api-version disagrees
Cada fila de la tabla de abajo se comprobó en una copia de la demo: la comprobación falla con código de salida 1 y nombra la guarda.
| Cambio | Lo detecta |
|---|---|
subir API_VERSION solo en web/src/api.ts |
join:api-version |
añadir un campo JSON solo en la estructura Order de Go |
set:order-fields |
| añadir un estado solo en el worker de Python | set:worker-statuses |
leer una variable de entorno nueva en el worker que no está en .env.example |
set:env-worker-vars |
llamar a /v1/refunds desde el cliente |
route:client-unexplained |
cambiar el redondeo en server/pricing.go |
pair:price-rounding |
En tu propio repositorio: crossweft init (init --example muestra antes una costura funcionando), y luego pídele a tu agente que mapee el repositorio; la skill lo guía paso a paso y crossweft check lo mantiene honesto. En Claude Code: /plugin marketplace add AnastasiyaW/crossweft, y después /plugin install crossweft@crossweft. En CI: uses: AnastasiyaW/[email protected], o crossweft check --format sarif para el escaneo de código de GitHub.
El repositorio de Crossweft protege sus propias costuras con Crossweft: la versión en tres archivos, los eventos de hook, los comandos de la documentación. Mientras escribíamos el README, la guarda "los comandos documentados existen" saltó tres veces por líneas de prosa que tomó por comandos. Corregimos también esas.
Crossweft es Apache-2.0. Las incidencias y los pull requests son bienvenidos en github.com/AnastasiyaW/crossweft.