Каждый стык, написанный вручную, нуждается в guard. Crossweft: проверка контрактов между языками для AI-агентов
Автор: Anastasiia Butova, инженер по ComfyUI и диффузионным моделям, Белград · опубликовано 2026-10-08
Перевод английской версии: English
Самые дорогие наши баги никогда не жили внутри файла. Они жили между файлами. Одно значение было записано в двух-трёх местах, на двух языках, кто-то поменял одно из них, всё продолжало собираться, тесты были зелёными, а установка ломалась на машине заказчика. Здесь описано, как мы сделали эти места явными и проверяемыми и встроили проверку в AI-агентов, которые пишут значительную часть нашего кода. Инструмент открытый: github.com/AnastasiyaW/crossweft, страница проекта: happyin.work/crossweft.
Десять границ, которых компилятор не видит
Мы делаем плагин для ретуши в Photoshop, который запускает нейросеть на машине пользователя. Между фотографом и моделью стоят десять границ доверия: защищённый канал между плагином и системной службой, регистрация устройства, подписанные артефакты с доверенными метками времени, аттестация сборки, канал обновлений, шифрование моделей и водяной знак. Части написаны на C++ и Go, работают в разных процессах и приходят через разные установщики.
Ни одна из этих границ не является модулем. Каждая — соглашение между двумя сторонами: одно и то же имя службы, одна и та же версия протокола, один и тот же набор маршрутов, одна и та же строка подтверждения. Сегодня 54 связи через эти границы несут 188 сравнений значений (468 мест в коде) и 105 сравнений множеств. Компилятор не видит ни одного из них, потому что стороны написаны на разных языках.
Мы стали называть такие места стыками: точками, где два компонента обязаны совпадать точно.
- маршрут, который вызывает клиент, и маршрут, который регистрирует сервер;
- заголовок с версией протокола;
- поля DTO в Go и в TypeScript;
- переменные окружения, которые читает служба, и те, что перечислены в
.env.example; - правило округления цены, записанное дважды, на двух языках.
Типы, тесты и линтеры хорошо работают внутри одного языка. Соглашения такого рода они редко проверяют, поэтому такие соглашения живут в памяти людей.
Название происходит от утка, нити, которая идёт поперёк основы и удерживает ткань вместе.
Три истории
Один идентификатор, три копии. Имя службы определялось в трёх почти одинаковых функциях. На чистой машине служба ещё не была установлена, поиск не срабатывал, пустая машина выглядела как старая несовместимая установка, и установщик останавливался с кодом выхода 76. Мы починили одну копию. Две другие остались сломанными.
Две половины одной зависимости. Сборка брала библиотеку из одного каталога установки, а её инструменты — из другого. Конфигурация лишь печатала предупреждение, поэтому проблема всплывала гораздо позже, там, где настоящую причину найти трудно.
Guard, который ничего не охранял. Валидатор одного из наших правил безопасности имел неверный путь. Неделями он проверял ноль файлов и каждый раз докладывал OK. Он проходил именно потому, что не запускался.
Причина во всех трёх случаях одна: соглашение имеет двух или трёх участников, и совпадают ли они, зависит от того, обратит ли кто-то внимание.
Почему агенты делают это хуже, и как они же помогают
Значительная часть кода пишется агентами, в основном Claude Code и Codex. С соглашениями между компонентами они справляются хуже людей. Типичный случай: агент меняет структуру Go, запускает тесты, видит зелёное и говорит «готово», ни разу не открыв TypeScript-интерфейс с другой стороны.
- Локальные правки. Вторая сторона стыка находится в другой папке, на другом языке, в другом процессе.
- Ограниченный контекст. Агент не держит в голове весь продукт, а компилятор не скажет ему, что где-то от этой структуры зависит клиент.
- Узкое понятие «готово». Для агента «готово» означает «каждая видимая ему проверка прошла». Если ни одна проверка не смотрит на стык, расхождение остаётся невидимым, пока что-нибудь не сломается.
Агенты хорошо следуют точным локальным инструкциям. Скажите агенту сразу после правки, какой файл с другой стороны открыть и что там должно совпадать, — и он это сделает. Не дайте ему завершить работу, пока стороны расходятся, и ошибка не дойдёт до коммита.
Правило: каждая связь говорит, как она поддерживается в согласии
Каждая связь между компонентами объявляет, как её две стороны остаются в согласии. Если обе стороны написаны вручную, связи нужен guard: проверка, которая читает обе стороны.
Связи живут в JSON-файлах рядом с кодом. Карта состоит из блоков (программы, службы, хранилища, файлы конфигурации) и связей между ними (HTTPS, именованный канал, файлы, генерация кода, переменные окружения). Каждое утверждение карты привязано к литералу в исходном файле, поэтому, когда код переезжает, карта не может молча описывать прошлое. Проверка падает.
У каждой связи есть поле contract.enforcement:
| Enforcement | Кто поддерживает стороны равными | Что требует проверка |
|---|---|---|
shared-code |
символ, который импортируют обе стороны | defined_in называет символ |
generated |
генератор из одного источника | defined_in называет источник |
schema-tests |
общие тестовые векторы | defined_in называет векторы |
duplicated |
никто, копируется вручную | guard, который читает файл с каждой стороны |
convention |
никто, это соглашение | guard, который читает файл с каждой стороны |
none |
никто | guard, который читает файл с каждой стороны |
Одна только классификация полезна. «Где мы всё ещё полагаемся на чью-то память?» превращается в список, который можно сосчитать. Guard, который читает лишь одну сторону, не засчитывается: инструмент проверяет, что он читает файлы на двух разных концах связи.
Три guard: join, set и pair
Guard намеренно простые: регулярные выражения, сравнение значений или наборов значений. Проверка занимает миллисекунды, не требует сборки и работает с любым языком.
join: значение одинаково везде. Версия API, которую TypeScript-клиент отправляет, и та, что ожидает 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 = \"([^\"]+)\""}]}
Сравниваются все вхождения, а не только первое. В первой версии проверялось только первое совпадение, поэтому вторая копия дальше по файлу могла проскочить.
set: члены совпадают. Поля TypeScript-интерфейса против JSON-тегов Go; статусы, которые пишет Python-воркер, против констант Go; переменные окружения, которые читает код, против .env.example. Намеренное различие идёт в allow с причиной, а запись в allow, которая больше не нужна, сама проваливает проверку.
pair: дублированная логика, у которой нет единого значения для сравнения. Например, округление цены в Go и в TypeScript. Оба участка помечены crossweft:begin price-rounding и crossweft:end price-rounding, и Crossweft снимает их отпечаток. Если любой из них меняется, проверка падает, пока кто-то не перечитает двойника и не выполнит crossweft attest price-rounding --reason "...". Причина попадает в lock-файл и в ревью.
Самый короткий путь из красного pair для агента — аттестовать сторону, которую он изменил, и никогда не трогать другую. Поэтому если с последней аттестации изменился только один участок, attest отказывает. Сначала почините двойника, а если он действительно уже эквивалентен, скажите это явно через --other-side-unchanged.
Есть и более крупные проверки. Каждый маршрут, который регистрирует сервер, должен быть на карте; Go chi читается нативно (также Express и Fastify, FastAPI, Flask, gin и echo), всё остальное — через регулярное выражение. Каждый вызов /v1/... в клиентском коде должен объясняться связью. Блок может отправлять только те данные, которые сам создал или получил. Каждая папка с исходниками должна быть на карте.
Когда встроенных видов не хватает, репозиторий добавляет собственные через плагины. У нас их 13. Один сравнивает 916 мест, где Go-сервер обращается к SQL, со 161 таблицей и 276 триггерами в схеме; другой проверяет 185 маршрутов сервера; третий проверяет 27 пар «ожидание ≥ работа + запас» для таймаутов. Плагин, который не импортируется, падает или ничего не сравнивает, — это ошибка, а не зелёный результат.
Что видит агент
Проверка — это половина дела. Агенту нужно узнать о стыке сразу после правки, а не тогда, когда код готов к коммиту. Это делают три хука:
- Старт сессии: короткая заметка, что в репозитории есть карта стыков, сколько их, и как с ними работать.
- После каждой правки: если файл лежит на стыке, агенту сообщают другую сторону и guard.
- Перед завершением: запускается
crossweft check. Если стык расходится, агент не может завершить работу и получает указание, что делать.
После правки серверного Go-кода в демо агент видит:
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.
Если он поднимает версию API в Go и пытается остановиться, хук возвращает его назад:
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).
Совет зависит от типа проблемы. Если литерал, на который указывает карта, исчез, агенту велят обновить якорь или вернуть код, а не «чинить другую сторону». Ключ заканчивается дайджестом того, какой файл содержит какое значение, поэтому зафиксированное исключение извиняет только это конкретное расхождение.
У хука остановки есть две классические ошибки: бесконечный цикл и тихое прохождение. Наш возвращает агента назад, только пока тот движется вперёд (набор падающих ключей меняется), не больше четырёх раз подряд. Без прогресса агент может остановиться, а человеку выводится явное сообщение, что проверка всё ещё красная.
Для Claude Code есть плагин с хуками и скиллом. Для Codex — плагин-скилл. Любой агент, который говорит по MCP, может подключить crossweft mcp и получить проверку, анализ влияния изменения и «какой файл перечитать» как инструменты только для чтения. Адаптеры хуков для Gemini CLI, Copilot и Cursor пока экспериментальные: их формат вывода протестирован, полный цикл — пока нет.
Проверки, которые не могут пройти молча
Третья история — валидатор, который неделями ничего не сканировал. Не каждое правило означает «A равно B»; иногда нужно доказать, что у константы один источник. Такие правила живут в маленьких скриптах, и именно они тихо ломаются.
Раннер crossweft validators проваливает валидатор, который завершился с ненулевым кодом, не печатает SCANNED: files=<n> items=<n> или печатает нули, не имеет самопроверки или проваливает её (SELF-TEST: checks=<n>, запущенная на подброшенной ошибке), или не укладывается в отведённое время. Валидатор, который действительно не применим, печатает APPLICABILITY: <reason> и получает SKIP, а не PASS.
Сам Crossweft работает так же. Исходов три, а не два: 0 означает, что всё совпадает, 1 — расхождение, 2 — проверка не состоялась (модель не читается, ничего не просканировано). Код выхода 2 никогда не выглядит как успех.
Реестр известного дрейфа, который не гниёт
Не каждое расхождение можно исправить в том же pull request. Тогда вы записываете finding: ключ проблемы, владельца и следующий шаг. Пока проблема есть, проверка проходит. Когда её исправили, finding устаревает и проваливает проверку, пока кто-то его не закроет. Сломанные якоря так не извиняются: карта не имеет права неверно описывать код.
Для репозитория, в котором дрейф уже есть, crossweft baseline записывает весь этот дрейф как открытые findings с владельцем. Проверка становится зелёной, новый дрейф её по-прежнему проваливает, а всё исправленное просит закрыть.
Что это изменило
Честный контекст сначала. С середины июля наш установщик никогда не поднимал системную службу на чистой виртуальной машине. Мы добавили проверяемые стыки 26–28 сентября, и 29 сентября служба впервые запустилась. Это совпадение по времени, а не доказательство причины: в то же время вошли и другие исправления.
Что изменилось на самом деле, так это характер сбоев. Три первых сбоя 28 сентября были стыками: код выхода 76 (имя службы в трёх копиях); сервер отказывался от загрузчика, который регистрировался под версией артефакта, а запрашивался под версией протокола; и HTTP 500, потому что два пути публикации удаляли старый пакет, а третий — нет. У каждого теперь свой guard. Следующие две проблемы были обычными багами, а не «одно значение в нескольких местах».
Что показывает карта, пересчитано по истории репозитория:
| 27 сент. | 29 сент. | 4 окт. | |
|---|---|---|---|
| Блоки / связи | 130 / 160 | 134 / 166 | 138 / 192 |
| join-guard | 10 | 220 (529 точек) | 261 (644 точки) |
| set-guard | 4 | 125 | 138 |
| Findings | 58, все открыты | 106: 93 закрыто, 10 открыто, 3 принято | 146: 117 закрыто, 25 открыто, 4 принято |
Первый проход «каждая связь объявляет своё enforcement» сделали сами агенты. За один день они добавили 185 join- и 107 set-guard и нашли 15 настоящих расхождений в коде. Из 93 закрытых findings у 91 есть коммит, который их исправил, а 58 из этих сообщений коммитов называют id finding из карты.
Карта без проверки устаревает за неделю. Через неделю после первого замера было 96 проблем, и 94 из них были устаревшей картой, а не багами в коде. Поэтому карту проверяют каждый раз, когда агент останавливается. Мы выросли с 4 валидаторов в июне до 50, и каждый из них должен доказать, что что-то просканировал.
Открытие и то, что поймал первый прогон CI
Инструмент вырос внутри коммерческого продукта, поэтому открывали мы его аккуратно. Есть две ветки: публичная oss, где только инструмент, и приватная internal, то есть oss плюс папка с нашими собственными правилами. Изменения текут из oss в internal, но не наоборот. Наружу уходит снимок дерева, один коммит на релиз с нейтральной подписью, а не наша история. Перед публикацией сканер проверяет снимок и метаданные коммитов по списку приватных слов, а свежий независимый ревьюер читает diff с прошлого релиза, потому что сканер знает только те слова, что ему дали. Первый обзор нашёл три фразы в документации, намекавшие на устройство нашего продукта, и мы их заменили до релиза.
Релизный тег ставится только тогда, когда CI зелёный на Linux, Windows и macOS, потому что теги неизменяемы. Самый первый прогон был красным. Он поймал две настоящие ошибки, которые 157 локальных тестов пропустили:
- код использовал аргумент, который появился только в Python 3.10, тогда как мы обещаем 3.9;
- плагин, отредактированный дважды за одну секунду до одинаковой длины, выполнялся из устаревшего байткода, потому что кеш Python различает файлы по секундам и размеру. Локально мы этого не видели: запись байткода на нашей машине разработки отключена глобально.
Мы исправили обе, второй прогон был зелёным, и v0.1.0 вышла. Через два дня v0.2.0 добавила MCP-сервер, check --changed, guard-ы, построенные по схемам OpenAPI и proto, и сканеры маршрутов для ещё пяти фреймворков. Это показывает суть этой статьи: зелёная проверка говорит лишь о тех видах ошибок, о которых кто-то уже подумал.
Разве такого ещё нет?
Каждая часть где-то существует. Мы не нашли именно сочетания, и правила, что каждая связь объявляет, как поддерживается её согласованность, и каждый написанный вручную стык имеет guard.
| Инструмент | Что делает | Чем отличается |
|---|---|---|
Google LINT.IfChange, ifttt-lint, if-changed |
«если этот блок меняется, поменяй и тот файл» | проверяет, что diff затронул файл; join сравнивает значения при каждом запуске |
| clevis | одно значение одинаково в файлах | нет множеств, нет участков, нет карты |
| Pact, buf, oasdiff | контрактные тесты и совместимость схем | правильный ответ, когда схема существует; Crossweft строит guard-ы по OpenAPI и proto для рукописной стороны |
| ArchUnit, dependency-cruiser, import-linter | правила архитектуры | внутри одного языка |
| archagent | инварианты архитектуры и скиллы для агентов | ближе всего по духу, но про структуру, а не про значения между языками |
| GitNexus, codegraph | графы кода и анализ влияния для агентов, через MCP | граф, который вы запрашиваете, а не проверка, которая падает; они дополняют друг друга |
| fiberplane/drift, Swimm | документация, привязанная к коду | хеш падает от любой правки; якорь падает, только когда сам факт исчез |
Подход тоже не наш. Биргитта Бёкелер пишет о harness engineering: направляющие, которые действуют до того, как агент пишет код, и датчики, которые проверяют после, детерминированные проверки против проверок, которые делает языковая модель. OpenAI пишет о том же в Harness engineering: leveraging Codex in an agent-first world. Crossweft — детерминированный датчик для стыков между компонентами, и его вывод говорит агенту точно, что исправить.
Ограничения
- Регулярные выражения, а не парсинг. Переформатируете объявление — проверка громко упадёт; вы обновляете шаблон. Но слишком широкий шаблон всё равно может пропустить настоящее расхождение, поэтому смотрите на извлечённые значения и проверяйте поведение во время выполнения обычными тестами.
- Честность enforcement проверяет человек. Карту строят агенты, и Crossweft не даст им выдумать связь или пропустить папку, но то, что связь действительно
shared-code, а не две копии, иногда может знать только разработчик. - Внедрение требует работы. На маленьком проекте выигрыш невелик. Окупается там, где много языков и процессов.
- Пока единственные пользователи — мы. Цикл для Claude Code проверен от начала до конца; другие агенты подключаются через MCP и скилл, а их адаптеры хуков экспериментальны.
check --changedускоряет pre-commit, но пока не тогда, когда правка затрагивает роутер или клиентский файл.
Попробовать
Быстрее всего увидеть механизм, если сломать демо (Go API, TypeScript-клиент, 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
Каждая строка таблицы ниже проверена на копии демо: проверка падает с кодом выхода 1 и называет guard.
| Изменение | Что его ловит |
|---|---|
поднять API_VERSION только в web/src/api.ts |
join:api-version |
добавить JSON-поле только в Go-структуру Order |
set:order-fields |
| добавить статус только в Python-воркер | set:worker-statuses |
читать новую переменную окружения в воркере, которой нет в .env.example |
set:env-worker-vars |
вызвать /v1/refunds из клиента |
route:client-unexplained |
поменять округление в server/pricing.go |
pair:price-rounding |
В своём репозитории: crossweft init (init --example сначала показывает рабочий стык), затем попросите агента описать репозиторий; скилл проведёт его по шагам, а crossweft check будет следить за честностью. В Claude Code: /plugin marketplace add AnastasiyaW/crossweft, затем /plugin install crossweft@crossweft. В CI: uses: AnastasiyaW/[email protected], либо crossweft check --format sarif для code scanning на GitHub.
Репозиторий Crossweft проверяет свои собственные стыки самим Crossweft: версию в трёх файлах, события хуков, команды в документации. Пока мы писали README, guard «документированные команды существуют» сработал три раза на строках прозы, которые принял за команды. Их мы тоже исправили.
Crossweft распространяется по Apache-2.0. Issues и pull requests приветствуются на github.com/AnastasiyaW/crossweft.