Модульность и распространение кода в flang
Этот документ отвечает на один вопрос: как flang будет раздавать и подключать чужой код. Разбирается гипотеза «библиотеки Борхеса» — имя функции есть хеш её собственного текста, установка пакета есть взятие пути в общем хранилище. Ровно так устроен язык Unison, поэтому Unison здесь не пересказан по документации, а установлен, запущен и измерен: все числа ниже получены на этой машине, а не взяты из чужих статей. Как именно — в разделе 10.
Документ ничего не меняет в компиляторе. Это решение с ценой, а не обзор.
1. Вывод, на который можно опереться
Взять из идеи ровно половину: хеш содержимого как идентичность внутри, имена и версии как интерфейс снаружи. Базу данных вместо файлов не делать.
Коротко, почему именно так.
Половина, которую надо взять, — самая ценная часть Unison, и она стоит дёшево. Хеш нормализованного определения даёт то, чего у flang нет и что ему нужнее, чем кому-либо: ключ, по которому кешируется проверка тотальности и доказательство теоремы. Unison кеширует по хешу результаты тестов — приятно, но терпимо и без этого. flang по тому же хешу кеширует доказательства, а доказательство стоит на три порядка дороже теста. Это единственный довод в пользу адресации по содержимому, который для flang сильнее, чем для Unison.
Половина, которую брать нельзя, — та, ради которой Unison всё это затевал. Главный выигрыш Unison — «конфликта версий не существует»: две библиотеки тянут разные версии третьей, обе живут рядом, программа собирается. В flang этот выигрыш недостижим при любой схеме адресации, потому что импорт в flang — это слияние объявлений в одно плоское пространство имён, а совпадение имён — ошибка FLANG_DUPLICATE_NAME ([flang/src/link.mjs:256](../flang/src/link.mjs)). Две версии одного модуля не слинкуются, как их ни адресуй. Чтобы они ужились, нужны квалифицированные имена, а это правка парсера, связывания и восьми печатников (14 287 строк в [flang/src/emit/](../flang/src/emit/)), каждый из которых сегодня печатает программу в одно плоское пространство имён целевого языка. Библиотека Борхеса требует сначала пространств имён, и стоят они дороже самой библиотеки.
Что делать — три шага, из них обязателен один.
| шаг | что | цена | что даёт |
|---|---|---|---|
| 1 | хеш нормализованного определения (digest) | ~250 строк JS + ~350 на flang + ~400 тестов, ~1 неделя | кеш проверки и доказательств; устойчивый ключ сертификата; фундамент для 2 и 3 |
| 2 | манифест + замок с хешами + хранилище ~/.flang/store | ~600 строк JS + ~250 на flang + ~500 тестов, ~2 недели | распространение кода, воспроизводимая сборка, «поставить библиотеку» |
| 3 | публичный индекс имён и версий | не сейчас | нужен, когда появится третья сторона, публикующая код |
Шаг 1 полезен сам по себе и не меняет ни синтаксиса, ни раскладки файлов, ни одного байта печати. Шаг 2 добавляет одно новое слово в шапку модуля и один файл в корень проекта. Шаг 3 — организационная задача, а не языковая, и до неё далеко.
От чего отказываемся, называя вещи своими именами.
- От «имя функции есть её хеш» в исходнике. Человек продолжает писать
использует «Списки». Хеши живут в замке и в хранилище, в тексте программы их нет. - От двух версий одной библиотеки в одной программе. Ромб зависимостей в flang решается как в Elm — подъёмом до одной версии, а не сосуществованием. Это следствие того, что импорт сливает имена, и менять это сейчас дороже, чем жить с ограничением.
- От переименования без диффа. В Unison переименование не меняет ни одного хеша и потому не порождает изменений в коде; в flang переименование остаётся правкой текста, которую видно в
git diff. Взамен остаются работающимиgit blame, ревью в GitHub и все сторожевые тесты репозитория, которые сегодня сверяют байты — а их много и они и есть гарантия качества.
Почему не полный Unison. Полная модель — это не схема именования, а вторая инфраструктура: база данных вместо файлов, менеджер кодовой базы вместо редактора, собственный хостинг (Unison Share появился именно потому, что git не подошёл), собственная история ревью (задача «Story for pull requests», unison#867, открыта с 2019 года). Оценка снизу — 15–25 тысяч строк и постоянное сопровождение второго инструментария. При этом главный выигрыш в flang недостижим (см. выше), а побочный — кеш по хешу — берётся шагом 1 за неделю. Соотношение цены и пользы не сходится ни при каком порядке величин.
Что в гипотезе владельца оказалось верно. Довод «flang чистый, у функции нет скрытого состояния, поэтому содержимое действительно её определяет» — верен, и он строже, чем у Unison, но не там, где ожидалось. Он не про пакеты. Он про то, что у flang есть что кешировать по хешу, кроме результатов тестов: меры, тотальность, постусловия, теоремы. Подробно — в разделе 7, включая место, где довод ломается: поверхностей языка четыре (ru/en/eo/zh), и один и тот же алгоритм, записанный русскими и китайскими словами, обязан давать один хеш, иначе экосистема расколется на четыре несовместимые.
2. Что в flang есть сегодня
Не «модульной системы нет» — она есть, но настолько узкая, что распространять код на ней нельзя. Факты, а не впечатления.
2.1 Язык знает про модули пять слов
В таблице поверхностей лексера ([flang/src/lexer.mjs:158](../flang/src/lexer.mjs)) на модульность работают ровно пять идентификаторов, каждый на четырёх поверхностях:
| смысл | ru | en | eo | zh |
|---|---|---|---|---|
| модуль | модуль | module | — | 模块 |
| экспорт | экспортирует | exports | eksportas | 导出 |
| импорт | использует | uses | uzas | 使用 |
| сузить | только | only | nur | 仅 |
| откуда | из | from | el | 从 |
Форма шапки:
модуль «Каталог»
экспортирует «Найти», «Добавить»
использует «Списки» из "../stdlib/lists.flang" только «Сумма», «Длина»
Слов import, package, namespace, version в языке нет. Разбирает шапку parseModuleHeader ([flang/src/parser.mjs:840](../flang/src/parser.mjs)).
2.2 Импорт — это слияние, а не пространство имён
Это записано прямым текстом в шапке [flang/src/link.mjs](../flang/src/link.mjs) и определяет всё остальное. Связывание берёт объявления импортированного файла и кладёт их в тот же плоский список, что и объявления своего файла. Квалифицированных имён нет: написать Списки.Сумма невозможно. Совпадение имён из двух модулей — диагностика FLANG_DUPLICATE_NAME (строки 260, 280, 318, 343), а не перекрытие и не выбор.
Отсюда три следствия, которые придётся держать в голове весь остаток документа:
- Двух версий одной библиотеки в программе быть не может. Не «неудобно», а не компилируется.
- У библиотеки проекта один входной модуль. Это уже записано как правило в [
docs/rukovodstvo/project-layout.ru.md](rukovodstvo/project-layout.ru.md), раздел 6, и выведено из того же ограничения. - Печать в цель тоже плоская. Печатник C заводит один именователь на всю программу и различает роли приставками, потому что «одно пространство имён C не различает конструктор варианта и функцию» ([
flang/src/emit/c.mjs:441](../flang/src/emit/c.mjs)). Так же устроены остальные семь.
2.3 Разрешение пути — только относительный путь по файловой системе
использует «М» из "..." — это resolve(dirname(текущийФайл), путь) ([link.mjs:429](../flang/src/link.mjs)). Никакого поиска по имени, реестра, версий, кеша. Ошибки: файла нет — FLANG_IMPORT_NOT_FOUND, цикл — FLANG_IMPORT_CYCLE, имя в использует не совпало с шапкой файла — FLANG_IMPORT_NAME.
Командная строка компилирует ровно один файл: options.file = positional[1] ?? "-" ([flang/bin/flang.mjs:992](../flang/bin/flang.mjs)). Всё остальное подтягивает связывание. Манифеста проекта нет: ни flang.json, ни .flang-project, ничего.
2.4 Слой связывания есть в двух экземплярах, и оба надо править
Утверждение «link есть на JavaScript, а на flang его нет» фактически неверно — это стоит знать до планирования работ, потому что удваивает цену любой правки.
| где | файл | строк |
|---|---|---|
| эталон на JS | [flang/src/link.mjs](../flang/src/link.mjs) | 570 |
| на самом flang | [flang/self/bootstrap/compiler.flang](../flang/self/bootstrap/compiler.flang) | 685 (70 функций) |
| сторож | [flang/test/link.test.mjs](../flang/test/link.test.mjs) | 1242 |
| сторож самораскрутки | [flang/test/self-bootstrap.test.mjs](../flang/test/self-bootstrap.test.mjs) | 1253 |
Версия на flang повторяет JS-версию функция в функцию: «Разрешить путь» (:83), «Загрузить» (:382), «Пройти импорты» (:404), «Сузить видимость» (:128), «Второй проход» (:529). Тест «связывание на flang повторяет src/link.mjs» сверяет JSON связанной программы побайтово и диагностики по коду и тексту. Значит, цена любой правки модульности — это тройка «JS, flang, сторож», и перепечатка порождённого C (bootstrap/kompilyator_flang.c, 121 023 строки).
Важное следствие для будущего хранилища: чтения файлов в языке нет вовсе. Исходники приезжают в компилятор на flang списком объектов «Исходник» {путь, текст} (комментарий compiler.flang:40). Это хорошая новость: хранилище по хешу — работа хозяина (JS или C), а язык остаётся чистым и не узнаёт, откуда взялся текст.
2.5 Стандартная библиотека подключается тем же относительным путём
[flang/stdlib/](../flang/stdlib/) — 12 файлов, 4301 строка, 185 функций. Прелюдии нет: автоматически не подключается ничего. Пишут использует «Списки» из "../stdlib/lists.flang", то есть привязываются к раскладке каталогов на диске. Мёртвый код не отбрасывается, отбор делает рука автора словом только: замер в [flang/stdlib/README.md](../flang/stdlib/README.md) показывает 26 260 байт печати при полном импорте против 2 880 при только «Сумма» — разница в девять раз. При этом только не досчитывает транзитивные зависимости: возьмёшь функцию, зовущую соседку, — получишь FLANG_UNKNOWN_NAME.
2.6 Хешей содержимого в сборке нет, но идея в репозитории уже живёт
Кеша сборки и адресации по содержимому нет: поиск по content.address, кеш сборк, хеш содерж не даёт ничего. Зато есть три готовых куска, на которые новая работа ляжет без спора:
- Сертификат доказательства —
sha256канонического JSON, [src/certificate.ts](../src/certificate.ts): поляdocument_digest,context_digest,certificate_digest. Это ровно «доказательство, привязанное к содержимому», только для документа FTS, а не для функции. - Рукопожатие распределённых узлов —
sha256напечатанного модуля, [flang/conc/bin/node.mjs:113](../flang/conc/bin/node.mjs). Там прямо сказано: хеш печати, а не исходника, «два разных исходника, дающих один и тот же модуль, — одна и та же программа». Это уже адресация по содержимому, и выбрана нормализация — печать. Ровно тот выбор, который придётся сделать заново для определений. - **Отвергнутая идея манифеста с
sha256входных.flang** — [bootstrap/README.md:96](../bootstrap/README.md). Отвергнута в пользу сверки байтов, но помечена как возможная.
2.7 Теоремы не переезжают через импорт
Отдельно, потому что это прямо относится к разделу 7. Связывание сливает типы, функции и категорные списки, а theorems, processes, plans берутся только у входного файла ([link.mjs:566](../flang/src/link.mjs)). Импортировал модуль — доказанные о нём теоремы не приехали. Сегодня это почти незаметно, потому что многофайловых проектов на flang мало (из 163 файлов .flang слово использует есть в 17). Как только код начнут раздавать, это станет главной дырой: раздаётся код, не раздаются доказательства о нём.
3. Как Unison устроен на самом деле
Ниже — не пересказ обещаний. Стоял ucm release/1.3.0 (сборка 2026-05-13), скачанный с GitHub; всё, что помечено «замер» или «опыт», выполнено на этой машине 2026-08-15.
3.1 Что именно хешируется
Определение опознаётся по 512-битному SHA3 от его синтаксического дерева. Документация говорит прямо: хеш есть «дайджест внутренней структуры терма или типа, исключая все имена» (Hashes). Перед хешированием дерево нормализуют: имена аргументов заменяются позиционными ссылками, каждая зависимость — своим хешем. Пример из документации: increment n = n + 1 хешируется как (#arg1 -> #a8s6df921a8 #arg1 1).
Опыт: объявленный тип входит в хеш наравне с телом. Добавил три определения с одним и тем же телом x -> x:
f : Nat -> Nat h : Nat -> Nat g : Text -> Text
f x = x h x = x g x = x
Результат names:
#0h3hsuemei Term f, h
#61d8apf76h Term g
f и h — это один объект с двумя именами: одинаковые определения не дублируются, они склеиваются. А g с тем же телом, но другой сигнатурой — уже другой хеш. Значит, объявленный тип входит в хеш наравне с телом.
Опыт: документация и тесты в хеш не входят. Дописал к f документацию, а затем тест — хеш f остался #0h3hsuemei, а тест получил собственный адрес #08qoqaps3v. В Unison f.doc и f.tests.ex1 — не поля функции, а отдельные определения, которые на неё ссылаются. Это важнейшая деталь всей конструкции, и в разделе 7 она решает вопрос про теоремы flang.
Взаимная рекурсия. Цикл хешируется целиком: адрес имеет вид #x.n, где x — хеш цикла, а n — номер члена внутри. Порядок членов канонический: они сортируются по своим индивидуальным хешам, посчитанным с вырезанным циклом. То есть перестановка функций в файле ничего не меняет, а вот состав цикла — меняет всё: добавил в клубок взаимно рекурсивных функций ещё одну, и все получили новые адреса.
Типы: две дисциплины. structural type хешируется чисто по структуре, и два типа с одинаковой формой — один тип: Suit из четырёх конструкторов и Direction из четырёх конструкторов взаимозаменяемы, что почти всегда не то, чего хотел автор. Поэтому по умолчанию тип unique: при создании ему выдаётся GUID, который и участвует в хешировании (Unique types). Иначе говоря, чистая адресация по содержимому оказалась негодной для типов, и в неё пришлось подмешать случайное число. Это стоит запомнить: даже в языке, построенном вокруг хеша содержимого, не всё определяется содержимым.
3.2 Имена и кодовая база
Имена — метаданные, лежащие рядом и на хеш не влияющие. Имя — указатель на адрес; поменять, куда указывает имя, можно, содержимое адреса — никогда.
Кодовая база — не каталог с текстами, а база SQLite: <проект>/.unison/v2/unison.sqlite3 (замер: этот файл и есть вся кодовая база, рядом только пустой lock-файл). Внутри — определения, полный граф зависимостей, индексы по типам и кеш компиляции, который «никогда не инвалидируется». Исходники .u — это черновики: пишешь в scratch.u, менеджер кодовой базы следит за файлом, проверяет типы при сохранении, а команда add или update переносит определения в базу. После этого текст черновика не нужен.
Зависимости лежат в пространстве имён lib внутри проекта. Ставятся командой lib.install @unison/base; конкретная версия — lib.install @unison/base/releases/7.0.0, и она попадает в lib.unison_base_7_0_0 (замер, вывод ls lib ниже в разделе 5). Публикуются проекты на Unison Share — собственный хостинг, появившийся потому, что git для нетекстового формата подошёл плохо.
3.3 Обновление зависимости и переименование
Обновление — это lib.install новой версии и затем update. Дальше работает распространение: у изменённого определения новый хеш, поэтому все зависящие от него определения тоже перехешируются, автоматически и рекурсивно. Незакрытые места собирает команда todo; пока они не закрыты, кодовая база не считается сломанной — старые адреса продолжают существовать и работать.
Переименование — move старое новое. Ни один хеш не меняется, потому что имена не в хеше. Проверка после переименования не перезапускается: тесты остаются в кеше (Cached test results), потому что кеш ключуется адресом, а не именем. Это действительно приятно и в текстовых языках так не бывает.
Правка тела — противоположность: меняется хеш, и по цепочке зависимостей перехешируется всё, что от функции зависит. Замер этой цены — в разделе 5.
3.4 Проверка главного обещания: «конфликтов версий не существует»
Обещание формулируется так: определения ссылаются друг на друга по хешу, а не по имени, поэтому конфликта версий быть не может. Проверил опытом — сценарий (а) из задания в чистом виде.
Завёл тип и две функции над ним, сохранил копию в lib.staraya (это «старая версия библиотеки»), затем изменил тип, как будто вышла новая версия:
было: unique type Money = Money Nat
стало: unique type Money = Money Nat Text
Обе версии спокойно живут рядом: старая в lib.staraya, новая в корне, ничего не сломалось, перекомпилировать старую не понадобилось. Затем попробовал передать значение новой версии в функцию старой:
proba = lib.staraya.vzyat (sdelat 5)
Ответ компилятора (дословно):
The 1st argument to `staraya.vzyat`
has type: Money
but I expected: staraya.Money
Вот честная формулировка того, что даёт Unison. Конфликт версий не исчезает — он меняет форму. Было: «не собирается вообще» или, хуже, «собралось, но подложили не ту версию». Стало: собирается всё, что не пересекается, а на границе между версиями — ошибка типов в точке передачи значения, с указанием обеих сторон. Это большой шаг вперёд, но это не «конфликта нет»: если две библиотеки должны обменяться значением типа из третьей, придётся писать переходник или поднимать обе до одной версии, как везде.
3.5 Шероховатости, которые видно сразу
Одна попалась в первом же опыте. После fork . lib.staraya имя конструктора Money стало неоднозначным, и компилятор отказался разбирать файл:
I couldn't resolve any of these names:
vzyat = cases Money n _ -> n
Name Type Suggestions
Money constructor Money.Money
lib.staraya.Money.Money
Пришлось писать Money.Money. Ничего страшного, но иллюстрирует общее: когда имена — просто метки на адресах, разрешение имени перестаёт быть однозначным и становится диалогом с инструментом. К этому привыкают, но это не бесплатно.
4. Один сценарий, три мира
Приложение «Заказы» подключает две библиотеки: «Оплата» и «Отчёты». Обе подключают третью — «Деньги». Дальше два больных случая.
4.1 Случай (а): разные версии третьей библиотеки
«Оплата» требует «Деньги» 1.x, «Отчёты» успели перейти на «Деньги» 2.x, где у типа Деньги появилось поле валюты.
npm. Ставятся обе: одна в node_modules/деньги, вторая вложенно в node_modules/отчёты/node_modules/деньги. Сборка проходит. Дальше — как повезёт. Пока значения не пересекают границу, всё работает. Как только «Оплата» передаёт объект в «Отчёты», начинается класс ошибок, который в JS ловится только на исполнении: instanceof возвращает false для объекта «из другой копии», глобальные реестры расходятся, а знаменитое «two copies of React» — это ровно он. Диагноза нет: типов нет, ошибка вылезает в другом месте и позже.
cargo. Ставятся обе, версии несовместимые по semver уживаются штатно. Но Rust типизирован, поэтому ошибка приезжает при сборке — правда, в самом недружелюбном из возможных видов:
expected struct `money::Money`, found struct `money::Money`
note: perhaps two different versions of crate `money` are being used?
Одинаковый текст, разные типы. Спасает только приписка в конце и cargo tree -d, который покажет дубли, — но первый раз это стоит человеку полдня.
Unison. Обе версии живут в lib рядом, каждая под своим именем (lib.base и lib.unison_base_7_0_0 — замер в разделе 5). Ничего не пересобирается, потому что пересобирать нечего: у старых определений прежние адреса и прежний кеш. На границе — ошибка типов, которую я получил в опыте 3.4:
The 1st argument to `staraya.vzyat`
has type: Money
but I expected: staraya.Money
Это лучше, чем у cargo, ровно на одно: имена в сообщении разные, и по ним сразу видно, кто из какой версии. И это то же самое, что у cargo, по сути: сосуществование двух версий, обмен значениями невозможен. Стоит проговорить прямо, потому что обещание «конфликта версий не существует» звучит громче, чем то, что реально происходит: конфликт не исчез, у него стало понятное сообщение и не стало этапа разрешения версий.
flang сегодня. Не собирается вовсе:
FLANG_DUPLICATE_NAME: тип «Деньги» объявлен в двух модулях: ...
Импорт сливает объявления, двух Деньги в одной программе быть не может. Единственный выход — поднять всех до одной версии.
flang после предлагаемых шагов. Ровно то же ограничение, но выясняется оно не на связывании, а на разрешении зависимостей, и сообщением по делу: «„Оплата“ просит „Деньги“ 1.4, „Отчёты“ просят 2.1, выбрана 2.1; „Оплата“ с ней не сходится — поднимите или закрепите». Это модель Elm и Go, а не Unison. Взамен исчезает целый класс проблем: никогда не бывает двух копий одного определения, а значит, не бывает и ошибок «одинаковый тип, но не тот».
4.2 Случай (б): в библиотеке нашли ошибку, её починили
В «Деньгах» неверно округлялась копейка. Автор починил одну функцию из сорока.
npm. Выходит 1.4.3. Вы делаете npm update, замок обновляется. Что именно изменилось — вопрос к changelog автора: технически вы получили новый архив целиком, и различить «поправлена одна функция» и «переписано полбиблиотеки» можно только диффом руками. Дополнительно: транзитивные зависимости с диапазоном ^1.4.0 получат новую версию сами, у себя, без вашего решения. Ваши тесты перезапустятся полностью: кеш прогона, если он вообще есть, ключуется файлами, а не отдельными определениями, и после подмены одного файла библиотеки считается недействительным весь.
Unison. У починенной функции новый хеш; у остальных тридцати девяти — прежние. Вы ставите новый релиз и говорите update. Дальше распространение: перехешируется починенная функция и всё, что от неё зависит, — по графу, без вашего участия. Незакрытое собирает todo. Ключевое: результаты тестов для всех незатронутых определений остаются в кеше и не перезапускаются, потому что кеш ключуется адресом. Вы физически видите объём изменения: столько-то определений сменило адрес, остальные — те же.
flang сегодня. Копируете новый файл поверх старого. Компилятор ничего не знает про «что изменилось» — он перепроверяет типы, тотальность, меры и постусловия всей программы заново, потому что кеша нет вовсе. На маленьком проекте это незаметно; на самораскрутке (flang/self/, 56 474 строк на flang) это уже цена, которую платят на каждом прогоне.
flang после шага 1. Компилятор считает хеш каждого определения и знает точно: сменили адрес одна функция и её зависимые. Проверка тотальности и доказательства для остальных берутся из кеша и не могут протухнуть неверно — адрес сменился бы, если бы содержимое изменилось. Это и есть та половина Unison, которую надо взять: не «пакеты по хешу», а «работа по хешу не переделывается».
5. Сколько это занимает места
Вопрос был задан прямо, поэтому отвечаю замерами, а не ощущениями. Всё ниже получено на этой машине 2026-08-15: ucm release/1.3.0, @unison/base версии 7.19.2, node v26.7.0, npm 11.19.0. Размеры — в байтах, командой du -sb. Воспроизведение — в разделе 10.
5.1 Кодовая база Unison
| что | байт | человеческим языком |
|---|---|---|
| только встроенные, без библиотек | 413 696 | 404 КиБ |
| пустой проект | 491 520 | 480 КиБ |
проект со стандартной библиотекой @unison/base 7.19.2 | 17 518 592 | 16,7 МиБ |
он же плюс base 7.0.0 второй версией | 19 206 144 | 18,3 МиБ |
| он же плюс версии 7.0.0, 7.5.0, 7.10.0, 7.16.0 (пять версий сразу) | 19 476 480 | 18,6 МиБ |
base 7.19.2 — это 9095 термов и 213 типов, всего 9308 определений; в них входят документация и тесты, потому что в Unison они тоже определения. Выходит 1882 байта на определение, включая индексы имён, граф зависимостей и кеш компиляции.
Сам инструмент — отдельно: ucm-linux-x64.tar.gz весит 38 191 876 байт (36 МиБ), и это разово на машину, а не на проект.
5.2 Во сколько раз это больше или меньше node_modules
Три проекта, поставленные тут же настоящим npm install:
| проект | пакетов | файлов | байт | на диске |
|---|---|---|---|---|
| бэкенд: express, pg, zod, dotenv, jsonwebtoken, pino | 108 | 1900 | 7 970 772 | 13 МБ |
| инструментарий TypeScript: typescript, vitest, eslint, @types/node, prettier | 153 | 2561 | 73 166 701 | 77 МБ |
| фронт: react, react-dom, vite, typescript | 38 | 2347 | 59 941 564 | 64 МБ |
глобальный кеш ~/.npm (был на машине до опытов) | — | — | — | 254 МБ |
Сопоставление честное настолько, насколько оно вообще возможно, — сравниваются разные вещи:
- Кодовая база Unison со стандартной библиотекой (16,7 МиБ) в 2,2 раза больше
node_modulesбэкенда по объёму данных и в 1,3 раза больше по занятому месту. Но в ней лежит вся стандартная библиотека языка плюс кеш компиляции, а вnode_modules— только то, что явно попросили, и никакого кеша. - Она же в 4,2 раза меньше
node_modulesобычного инструментария TypeScript, который никто не считает чем-то тяжёлым. - Она же в 15 раз меньше глобального кеша npm, который на этой машине уже лежал и который по устройству — тот же вечно растущий склад по содержимому, только без единого полезного свойства кодовой базы.
Вывод по месту: вопрос закрыт, это не проблема. Порядок величин — десятки мегабайт, ровно тот же, к которому все привыкли, и заметно меньше, чем платит средний фронтенд за сборщик.
5.3 Что растёт со временем
Старые версии действительно не удаляются. Два замера отвечают на «сколько за год» с двух сторон.
Сторона первая: чужие библиотеки. Четыре дополнительные версии base, покрывающие развитие библиотеки с ноября 2025 по июнь 2026, добавили к базе 1 957 888 байт — 1,87 МиБ на четыре версии, примерно 478 КиБ на версию, то есть 2,8 % от размера первой копии. Это и есть цена адресации по содержимому: вторая версия библиотеки хранится не целиком, а только тем, чем отличается от первой.
Для сравнения масштаба: за 12 месяцев (с 2025-08-15) у @unison/base вышло 53 релиза — число из API Unison Share, не оценка. Если бы кто-то держал в кодовой базе каждый из них, линейная экстраполяция замера даёт около 25 МБ в год. Это оценка, а не измерение, и, скорее всего, завышенная: мои четыре образца отстоят друг от друга на пять релизов, а соседние релизы отличаются меньше. Реальная практика — держать одну версию и иногда вторую, то есть единицы мегабайт.
Сторона вторая: свой код. Здесь растёт быстрее, и вот почему. Правка одной функции меняет её хеш, а значит, и хеши всех зависящих от неё определений, — и старые никуда не деваются. Замер: цепочка из 50 определений (каждое зависит от предыдущего), затем 20 правок самой первой функции.
| что | байт | прирост |
|---|---|---|
база (только base) | 17 518 592 | — |
| плюс 50 определений цепочки | 17 764 352 | +245 760 (4,9 КБ на определение) |
| плюс 20 правок корня цепочки | 18 710 528 | +946 176 |
Двадцать правок породили 1000 новых версий определений и стоили 946 176 байт: 47 КБ на правку, 946 байт на новую версию определения. Определения в опыте крошечные (f n = g n + 3), так что почти весь этот вес — служебный: записи истории, индексы имён, гранулярность страниц SQLite.
Прикидка на команду — снова оценка, а не замер: если разработчик делает 30 содержательных правок в день, и каждая задевает в среднем десяток зависимых определений, выходит 300 новых версий в день, около 284 КБ в день, порядка 70 МБ на человека в год. Пятеро — треть гигабайта в год.
Это много или мало? Ровно столько же уже платит любой проект на git и никто не жалуется: .git этого репозитория весит 49 МБ и хранит всю историю всех версий всех файлов. Git — тоже хранилище с адресацией по содержимому, и вопрос «старое не удаляется, сколько за год» решён в нём тем же способом: никак, и это оказалось приемлемо. Для масштаба, весь исходный код на flang в этом репозитории — 163 файла, 3 556 450 байт, 3,4 МиБ.
6. Удобно ли этим пользоваться
Честно с обеих сторон, потому что решение принимается не по списку достоинств.
6.1 Что действительно проще
Установка ничего не собирает. Зависимость приезжает уже разобранной, проверенной и скомпилированной: кеш компиляции лежит в кодовой базе и «никогда не инвалидируется». Знакомая картина «npm ci три минуты, потом сборка ещё пять» исчезает не оптимизацией, а по устройству.
Разрешения версий нет как этапа. Не нужен решатель ограничений (задача, к слову, NP-полная — именно её решают npm, cargo, pip), потому что выбирать не из чего: адрес либо есть в хранилище, либо его дотягивают.
Откат не может не собраться. Старое определение никуда не делось, откат — это перевести имя обратно на прежний адрес. Ни пересборки, ни «а вдруг старая версия уже не встаёт с новым компилятором».
Кеш проверок ключуется содержимым, а не временем файла. После переименования тесты не перезапускаются (Cached test results) — просто потому, что адрес не изменился. Ни один инструмент на текстовых файлах так не умеет: make смотрит на дату, npm test перезапускает всё.
Одинаковый код существует в одном экземпляре. Замер из 3.1: две функции с одинаковым определением получили один адрес и два имени. Дублирование кода перестаёт быть вопросом дисциплины и становится физически невозможным.
«Работает у меня» ломается по-другому. Адрес фиксирует не версию, а именно тот код, включая всю транзитивную глубину. Подменить содержимое под тем же адресом нельзя.
6.2 Что хуже, и это надолго
Диффа нет. Изменение — это список определений, сменивших адрес, а не построчный дифф. Привычное «посмотреть, что поменялось в этой функции» превращается в «сравнить два дерева» — инструмент это умеет, но глазами так не читают. Отсюда же и главная незакрытая дыра: задача про рабочий процесс pull request'ов (unison#867) открыта с 2019 года.
Git перестаёт быть местом, где живёт код. Не «неудобно с git», а код лежит в другом месте: git может хранить файл базы, но git diff, git blame, git bisect, git log -p по нему бессмысленны. Unison Share появился именно поэтому. Для этого репозитория это приговор отдельно: вся дисциплина качества здесь построена на сверке байтов в git — самораскрутка сверяет побайтово порождённый C, журнал печатается из тем коммитов, тесты сравнивают JSON побайтово. Перевод кода в базу данных обнуляет не «удобство», а метод проверки.
Ломается всё, что читает текстовые файлы. grep, sed, патч из письма, покрытие, сторонние анализаторы, любой инструмент, который умеет .flang и не умеет вашу базу. Инструментов вокруг текста накоплено больше, чем кажется, пока их не отняли.
Имена перестают разрешаться однозначно. Живой пример из моего первого же опыта — после копирования пространства имён конструктор Money стал неоднозначным, и компилятор потребовал уточнения. Это не редкость и не ошибка инструмента, это следствие того, что имя — метка, а меток может быть много.
Порог входа. Прежде чем написать первую функцию, надо усвоить модель: черновик, кодовая база, add, update, пространства имён, распространение. Для языка, который сам по себе непривычен (чистота, тотальность, доказательства), это второй непривычный слой поверх первого.
6.3 Что просто непривычно, но не хуже
- Код не в файлах. Так жил Smalltalk, так живут базы данных. Через неделю перестаёт замечаться.
- Удаление удаляет имя, а не код. Определение остаётся достижимым по адресу. Сначала кажется утечкой, потом — страховкой.
- Сломанного состояния не бывает. Незаконченный рефакторинг — это набор имён, которые ещё не переведены на новые адреса; собирается и работает всё, что уже переведено. Списком незакрытого занимается
todo. - Переименование бесплатно. Ноль изменений в коде, ноль перепроверок.
7. Что адресация по содержимому даёт именно flang — и чего не даёт
Довод владельца звучал так: flang чистый, функции тотальные, скрытого состояния нет — значит, содержимое действительно определяет функцию. Довод надо проверять по частям, потому что верна в нём одна половина, а полезна другая.
7.1 Проверка довода
Чистота — верно, и это сильнее, чем у Unison. Ввода-вывода в языке нет вовсе: среди встроенных форм нет ни одной, которая читает файл, ходит в сеть или спрашивает время. У Unison иначе — там есть эффекты (abilities), и функция может их совершать; чтобы хеш оставался честным, эффекты пришлось загнать в тип, а тип — в хеш. flang этой сложности не несёт. Но выигрыша сверх Unison тут нет: Unison уже решил задачу типом, просто дороже. Чистота даёт право считать хеш идентичностью, и на этом её роль заканчивается.
Тотальность — верно наполовину. В языке два класса, тотальная функция и обычная ([flang/SPEC.md](../flang/SPEC.md), таблица в начале); только тотальный класс годится для встраиваемого факт-чекинга. Так что «функции тотальные» — это про часть корпуса, а не про язык. Для адресации это, впрочем, неважно: хешируется определение, а не его поведение.
Настоящая прибыль в другом месте, и она одна. У flang есть то, чего нет ни у Unison, ни у npm: дорогая проверка. Тотальность, убывание меры, постусловия, теоремы — всё это компилятор считает заново на каждом прогоне, потому что кеша нет вовсе (см. 2.6). Unison кеширует по хешу результаты тестов; это приятно. flang по тому же хешу кеширует доказательства, а доказательство дороже теста на порядки. Вот единственный довод в пользу адресации по содержимому, который для flang весит больше, чем для Unison. Он не про пакеты вообще.
7.2 Входят ли в хеш постусловия, меры, теоремы
Вопрос поставлен точно, и ответ у него не одинаковый для всех трёх.
Постусловие и мера — входят, и обязаны. обеспечивает лежит полем функции (postconditions в узле функции), убывает стоит между возвращает и телом и разбирается в области видимости параметров ([flang/SPEC.md](../flang/SPEC.md), «Объявленная мера», «Постусловия функции»). Это не примечания — это то, что компилятор обязан доказать и на что вызывающая сторона имеет право опереться. Функция с постусловием «результат не меньше нуля» и такая же функция без него — разные обязательства, и путать их адресом нельзя. Если бы постусловие в хеш не входило, кеш доказательств стал бы неверным: сохранили бы доказательство для одного контракта, а применили к другому.
Цена этого решения признаётся прямо: дописал постусловие — сменился адрес, сменились адреса всех зависимых, пересчиталось всё, что от них зависело. Даже если постусловие только усиливает уже верное. Это неприятно, но правильно: для верифицирующего компилятора усиление контракта и есть новая функция.
Теорема — не входит, и не должна. Тут решение подсказал сам Unison, и я его измерил (опыт в 3.1): документация f.doc и тест f.tests.ex1 — не поля функции, а отдельные определения, которые на функцию ссылаются; хеш f от их появления не меняется. Теорема flang устроена ровно так же: theorems — это список верхнего уровня, а не поле функции ([flang/SPEC.md](../flang/SPEC.md), «Теорема»). Значит, у теоремы должен быть свой адрес, а внутри — ссылка на функцию по хешу.
Отсюда прямой ответ на вопрос «доказательство привязано к телу навсегда — это хорошо или плохо». Оно привязано не к телу, а к паре: адрес функции плюс утверждение. И это именно то, чего хочется:
- доказательство остаётся верным ровно столько, сколько цел адрес функции;
- поправили функцию — доказательство не «протухло молча», оно осталось верным для старой функции, которая никуда не делась, а для новой его просто нет, и это видно;
- доказательство можно раздавать отдельно от кода и проверять у себя, потому что в нём написано, о чём именно оно.
И это закрывает сегодняшнюю дыру. Связывание берёт theorems только у входного файла ([link.mjs:566](../flang/src/link.mjs)): импортировал модуль — теоремы о нём не приехали. Пока проектов на flang мало, это незаметно; как только код начнут раздавать, окажется, что раздаётся код без доказательств — то есть ровно без того, ради чего язык существует. Теорема с собственным адресом и ссылкой на хеш функции — единственный известный мне способ починить это так, чтобы теоремы ездили вместе с библиотекой и проверялись на месте.
Третий ключ, о котором легко забыть. Доказательство верно относительно проверяющего. Кеш, ключом которого служит только хеш функции, однажды сохранит доказательство, сделанное сломанной версией компилятора, и будет отдавать его годами. Ключ обязан быть тройкой: хеш функции, хеш утверждения, хеш проверяющего. В репозитории эта мысль уже есть: у сертификата доказательства рядом с document_digest лежит context_digest ([src/certificate.ts](../src/certificate.ts)).
7.3 Что тогда значит «та же функция»
Формулировка должна быть скучной и точной, иначе она подведёт: та же функция — это совпадающее нормализованное дерево разбора, где сняты позиции, стёрты имена (свои и локальные), а ссылки на другие определения заменены их хешами.
Чего это не значит: одинакового поведения. Две разные реализации сортировки дают два разных адреса, и никакая адресация по содержимому этого не изменит — внешнее равенство функций неразрешимо. Адрес даёт тождество, а не эквивалентность, и путать их нельзя.
Но именно здесь у flang есть ход, которого нет ни у кого: эквивалентность двух разных адресов можно доказать и записать теоремой — отдельным объектом, связывающим два хеша. У Unison такого объекта быть не может, потому что нечем доказывать. Это не работа на завтра, но направление, в котором «библиотека Борхеса» для flang становится чем-то большим, чем склад.
7.4 Где довод ломается: четыре поверхности
Самое опасное место — не теоремы, а то, о чём легко не подумать. У flang четыре поверхности: модуль / module / — / 模块. Один и тот же алгоритм, набранный русскими и китайскими словами, — одна функция, и хеш обязан быть один. Иначе экосистема расколется на четыре несовместимые, и это не гипотеза, а арифметика: одна и та же библиотека будет лежать в хранилище четырежды под разными адресами.
Хорошая новость: в репозитории эта задача уже решена и проверена тестом. В [flang/test/surface-pair.mjs](../flang/test/surface-pair.mjs) (208 строк) и [flang/test/surfaces.test.mjs](../flang/test/surfaces.test.mjs) (253 строки) две поверхности одной программы сверяются деревьями «с точностью до переименования, позиции сняты» — там же лежит готовая таблица, какой слот дерева считается именем, а какой сверяется побуквенно (литерал.value — данные, builtin.name — канонический идентификатор, kind и op — сам разбор). Это и есть половина работы по нормализации, уже написанная и защищённая тестом.
Плохая новость — два места, где придётся принять неприятные решения.
Имена вызываемых функций. Сегодня вызов записан именем, и имя же служит ключом при слиянии модулей. Чтобы хеш не зависел от имени, нормализатор обязан построить граф зависимостей и заменить вызов по имени вызовом по адресу, а для взаимно рекурсивных клубков — сделать то же, что Unison: хешировать клубок целиком, а членов нумеровать в порядке, полученном сортировкой по их собственным хешам с вырезанным клубком. Это самая содержательная часть работы.
Типы придётся испортить нарочно. Урок Unison, который стоит принять сразу: чисто структурный хеш типов негоден. Запись { значение: число } под именем «Метры» и такая же под именем «Секунды» — один адрес и, стало быть, один тип. Unison прошёл этот путь до конца и вернулся: по умолчанию тип unique, ему при создании выдаётся случайный GUID, и GUID участвует в хешировании. То есть даже в языке, построенном вокруг содержимого, не всё определяется содержимым, и это не недоделка Unison, а свойство задачи. flang придётся сделать так же — либо мириться с тем, что «Метры» и «Секунды» неразличимы.
8. Альтернативы, и какая из них даёт 80 % пользы дешевле
8.1 Nix: адресация по содержимому на уровне сборки
В Nix каждый результат сборки лежит в /nix/store/<хеш>-<имя>, каталог неизменяем, а версии сосуществуют без всяких усилий — у них просто разные пути. Внешне это ровно «библиотека Борхеса», но хеш там не от содержимого, а от рецепта: от входов, флагов и всей замкнутой цепочки зависимостей. То есть отвечает он на вопрос «это та же сборка?», а не «это та же функция?».
Самое поучительное в Nix — то, что случилось с настоящей адресацией по содержимому. Её добавили (ca-derivations) в версии 2.4 в ноябре 2021 года ради дедупликации и «раннего отсечения» пересборок — и по сей день, пять лет спустя, она остаётся экспериментальной, за флагом (wiki.nixos.org). А неизменяемое хранилище с точной замкнутой цепочкой зависимостей работает в проде двадцать лет и приносит почти всю пользу.
Урок для flang, и он главный во всём разделе. Для раздачи кода полезная часть — не хеш содержимого, а неизменяемое хранилище плюс точная фиксация того, что взято. Хеш содержимого добавляет к этому дедупликацию и раннее отсечение пересборок, и в Nix эта добавка за пять лет так и не окупилась настолько, чтобы выйти из-под флага.
Поэтому в моём плане хеш определения стоит первым не ради пакетов. Ради пакетов его можно было бы не делать вовсе — хранилищу хватает хеша файла модуля. Он стоит первым ради того, чего у Nix нет вовсе: кеша дорогих проверок и доказательств (раздел 7.1).
8.2 Go: имена и версии снаружи, хеши внутри
Go выбирает версии минимальным выбором версий (MVS): в графе берётся максимум из минимально требуемых. Решателя нет, перебора нет, задача сводится к достижимости в графе и решается линейно — в отличие от общей задачи разрешения версий, которая NP-полна и которую решают npm, cargo и pip. Расплата признана автором честно: «немного меньше автоматической свежести» в обмен на «огромное упрощение». Сборка воспроизводима без замка, потому что результат выбора зависит только от графа.
Целостность при этом обеспечена ровно хешами: go.sum хранит хеш содержимого каждого модуля, а рядом стоит публичный журнал контрольных сумм. Подменить содержимое под тем же именем и версией нельзя.
Это и есть форма, которую я предлагаю для flang. Снаружи — имена и версии, понятные человеку и годные для разговора. Внутри — хеши, отвечающие за тождество и целостность. Ромб решается подъёмом до одной версии, а не сосуществованием, и это честно объявлено ограничением, а не выдаётся за отсутствие проблемы.
8.3 Elm: семантику версий проверяет компилятор
В Elm номер версии не назначают, его вычисляют: elm diff сравнивает публичный интерфейс двух версий, elm bump по этому сравнению сам решает, что поднимать — мажор, минор или патч. Соврать нельзя: реестр не примет пакет с номером, который не соответствует изменению интерфейса. Двух версий одного пакета в сборке не бывает — ромб обязан быть разрешён подъёмом.
Здесь у flang редкая возможность обойти образец. Elm сравнивает сигнатуры типов — это всё, что он умеет назвать интерфейсом. У flang интерфейс богаче: типы, постусловия, объявленная мера, тотальность. Как только есть хеш нормализованного определения (шаг 1), «дифф интерфейса» считается механически, и можно запретить выпуск с номером патча, если изменилось постусловие. Это дёшево и это ровно та проверка, которой ни у кого нет.
8.4 Сводка
| подход | что даёт | чего стоит | берём? |
|---|---|---|---|
| Unison целиком | ромб не мешает; кеш по хешу; бесплатное переименование | база данных вместо файлов, свой хостинг, ревью без диффа; 15–25 тыс. строк | нет |
| Nix | неизменяемое хранилище, точная цепочка зависимостей, версии рядом | своя модель сборки; адресация по содержимому пять лет как эксперимент | идею хранилища — да |
| Go (MVS) | простой и предсказуемый выбор версий, целостность по хешам | ромб решается подъёмом, свежесть не автоматическая | да, это основа |
| Elm | номер версии не врёт, его считает компилятор | одна версия пакета на сборку | да, вторым заходом |
| хеш определения (из Unison) | кеш дорогих проверок и доказательств | нормализация дерева, GUID у типов | да, это шаг 1 |
Дешевле всего и с наибольшей отдачей: хранилище Nix + выбор версий Go + хеш определения из Unison, без кодовой базы Unison.
9. Что делать: план, цена в файлах и строках
Сначала одно различение, без которого план читается неправильно.
Хранилище адресует модули, кеш адресует определения. Это разные задачи и разная гранулярность, и путать их дорого:
- чтобы раздавать код, достаточно хеша файла модуля целиком — это просто и делается за день;
- чтобы не переделывать проверку и доказательство, нужен хеш каждого определения — это и есть содержательная работа.
Unison делает второе и получает первое даром. Нам выгоднее сделать их по отдельности — и шаги независимы: если раздача кода понадобится раньше, шаг 2 можно делать первым, хеш определения ему не нужен. Порядок ниже отражает ценность, а не зависимость.
Шаг 1. Хеш нормализованного определения
Что появляется:
| файл | строк | что делает |
|---|---|---|
flang/src/digest.mjs (новый) | ~250 | нормализация узла (снять span, стереть имена, заменить ссылки хешами, свернуть взаимно рекурсивные клубки) и sha256 канонического JSON |
flang/test/digest.test.mjs (новый) | ~400 | устойчивость и чувствительность (см. ниже) |
flang/bin/flang.mjs (правка) | ~40 | команда flang digest <файл> — печать адресов определений |
flang/src/failures.mjs или новый кеш (правка) | ~80 | ключ кеша проверок: тройка «адрес определения, адрес утверждения, версия проверяющего» |
Итого около 770 строк, из них 400 — тесты. Оценка недели работы.
Что обязан проверять тест, иначе шаг бесполезен:
- устойчивость: переименование локальных переменных, перестановка функций в файле, смена поверхности (ru/en/eo/zh) адрес не меняют. Корпус для последнего уже собран в [
flang/test/surfaces.test.mjs](../flang/test/surfaces.test.mjs), а таблица «что имя, а что данные» — в [flang/test/surface-pair.mjs](../flang/test/surface-pair.mjs); это и есть причина, по которой шаг 1 стоит недели, а не месяца; - чувствительность: правка тела, типа, постусловия, объявленной меры или пометки тотальности адрес меняют — каждый случай отдельным тестом;
- клубки: добавление функции во взаимно рекурсивную группу меняет адреса всех её членов, а перестановка внутри группы — ничьих.
Чего в шаге 1 нет: изменений синтаксиса, изменений печати, изменений в link.mjs, двойника на flang. Двойник понадобится только когда компилятор на flang начнёт сам решать, что перепроверять; сегодня хеш считает хозяин, а язык о нём не знает — и это согласуется с тем, что в языке нет ввода-вывода.
Шаг 2. Манифест, замок, хранилище
Синтаксис языка не меняется вовсе — и это осознанный выбор, потому что новое ключевое слово стоит таблицы на четырёх поверхностях. Меняется только правило чтения строки в из "...":
использует «Списки» из "../stdlib/lists.flang" как сегодня: путь по файлам
использует «Списки» из "списки" новое: имя библиотеки из замка
Разделяются они однозначно: есть / или расширение .flang/.fl — путь; нет — имя библиотеки. Всё, что собирается сегодня, собирается и завтра.
| файл | строк | что делает |
|---|---|---|
flang/src/manifest.mjs (новый) | ~200 | чтение flang.json (имя, версия, зависимости) и flang.lock (имя → версия → хеш) |
flang/src/store.mjs (новый) | ~180 | ~/.flang/store/sha256/<хеш>: положить, взять, проверить |
flang/src/versions.mjs (новый) | ~120 | минимальный выбор версий по графу зависимостей (алгоритм Go) |
flang/src/link.mjs (правка из 570) | ~150 | разрешение имени библиотеки через замок рядом с нынешним разрешением пути |
flang/bin/flang.mjs (правка) | ~120 | корень проекта, команды flang deps, flang lock |
flang/self/bootstrap/compiler.flang (правка из 650) | ~100 | то же разрешение имени по списку источников (файлов язык по-прежнему не читает) |
flang/test/manifest.test.mjs, store.test.mjs, дополнения к link.test.mjs | ~500 | ромб, отсутствующий хеш, испорченный хеш, обратная совместимость путей |
Итого около 1370 строк, из них 500 — тесты, плюс перепечатка bootstrap/kompilyator_flang.c (порождённый файл, но сторож сверяет байты — перепечатать обязательно). Оценка двух недель.
Шаг 3. Публикация и индекс — не сейчас
Пока код публикует один человек, «публикация» — это положить файл в хранилище и дописать строку в замок. Индекс имён, журнал контрольных сумм и права на имя — задача организационная, и заводить её раньше третьей стороны, публикующей код, значит поддерживать пустой реестр.
Что сделать заодно, потому что дальше будет дороже
Теоремы обязаны переезжать через импорт. Сегодня связывание берёт theorems только у входного файла ([link.mjs:566](../flang/src/link.mjs)). Как только появятся библиотеки, это превратится в «код раздаётся, доказательства — нет». Правка невелика (в связывании — десятки строк), но требует решить, как теорема ссылается на функцию: по имени, как сейчас, или по адресу. Правильный ответ — по адресу, и потому этот вопрос закрывается вместе с шагом 1, а не после него.
Цена отказа: во что обошёлся бы полный Unison
Не оценка «на глаз», а перечисление того, что придётся написать заново: менеджер кодовой базы вместо файловой раскладки; хранилище определений с индексами вместо link.mjs (570); переписанный REPL (1104) и языковой сервер (900), которые сегодня работают с буферами файлов; собственный протокол обмена вместо git; собственный показ изменений вместо git diff; и — главное — замена всей сегодняшней дисциплины сверок, где качество держится на побайтовом сравнении порождённых артефактов. Пятнадцать-двадцать пять тысяч строк и второй инструментарий на постоянном содержании. За это покупается ромб зависимостей, который в flang всё равно не работает без квалифицированных имён, и бесплатное переименование.
Как понять, что это решение было неверным
Одно наблюдение опровергнет весь план: если появятся две независимые библиотеки от разных авторов, обе нужные в одной программе и обе тянущие несовместимые версии третьей, — подъём до одной версии перестанет быть выходом. Тогда придётся делать квалифицированные имена (парсер, связывание, восемь печатников), и разговор про кодовую базу Unison вернётся уже на других основаниях. Пока весь корпус — 163 файла, из которых использует встречается в 17, этого не случится ещё долго.
10. Откуда числа
Всё измеренное получено 2026-08-15 на одной машине: Linux x86-64, ucm release/1.3.0 (сборка 2026-05-13, ucm-linux-x64.tar.gz, 38 191 876 байт), node v26.7.0, npm 11.19.0.
Как мерились кодовые базы. UCM умеет прогонять сценарии из markdown-файла (ucm transcript файл.md), а ключ -S каталог сохраняет получившуюся кодовую базу вместо удаления. Размер — du -sb по каталогу .unison; внутри лежит один файл v2/unison.sqlite3 и пустой lock-файл, так что мерился именно он. Последовательность: пустая база → project.create test (ставит @unison/base) → lib.install @unison/base/releases/7.0.0 и далее. Сценарий с правками (50 определений цепочкой, 20 правок корня) порождался скриптом, чтобы правки были одинаковыми.
**Как мерились node_modules.** Три каталога с рукописным package.json, обычный npm install, размер — тем же du -sb, число файлов — find -type f.
Что важно знать о самих числах.
du -sbдаёт объём данных,du -sh— занятое на диске место. Для кодовой базы Unison это одно и то же (один файл), дляnode_modulesрасходится заметно (7 970 772 байта против 13 МБ на диске) — тысячи мелких файлов оплачиваются размером блока. В сравнениях я брал объём данных, то есть сравнение в пользуnode_modules.- Прирост на 50 крошечных определений (4,9 КБ на определение) сильно завышен гранулярностью страниц SQLite и служебными записями; на этот показатель я выводов не строил. Показатель на правках (47 КБ на правку, 946 байт на новую версию определения) устойчивее — там тысяча объектов, а не пятьдесят.
- Число релизов
@unison/base(53 за 12 месяцев, последний 7.19.2 от 2026-06-01) взято из API Unison Share (api.unison-lang.org/users/unison/projects/base/releases), это данные, а не оценка. - Прогнозы «25 МБ в год на историю библиотеки» и «70 МБ в год на разработчика» — линейные экстраполяции замеров, а не замеры. Помечены как оценки в тексте и держатся на предположениях, которые я назвал там же.
- Числа по репозиторию flang (строки, файлы, номера строк) получены
wc -lи чтением исходников на коммите8203f39.
Мелочь, которая стоила времени и может стоить его снова. В пустом проекте (project.create-empty) встроенные имена вроде Nat не разрешаются, поэтому первый вариант опыта с правками не собрался; всё меряется поверх проекта с установленной base. И ucm в интерактивном режиме падает, если в системе исчерпаны экземпляры inotify (initINotify: resource exhausted), — режим сценариев этим не страдает.
Источники, на которые опирается пересказ устройства Unison (всё остальное — собственные опыты):
- Hashes — что входит в хеш, вид адреса, порядок членов цикла;
- The big idea — имена как метаданные, кодовая база, кеш, который не инвалидируется;
- Unique types — почему у типов появился GUID;
- Projects — пространство
lib,lib.install; - A tour of Unison — черновик,
add/update, распространение,move, кеш тестов; - unison#867 — открытая с 2019 года задача про рабочий процесс pull request'ов;
- Ca-derivations — состояние адресации по содержимому в Nix;
- Minimal Version Selection, Russ Cox — алгоритм выбора версий в Go и его обоснование.