контракт 2.9.0 · NativeAOT · только git

Одна рамка качества над разными репозиториями.

Harness — standalone CLI, который читает tracked .harness.json, измеряет то, что можно одинаково проверить в любом репозитории, и объясняет через ошибки, как должно быть. Тесты, сборку и линтеры проекта он не запускает: за них отвечает CI самого проекта. Харнес держит рамку и считает то, что не воспроизводит чужой пайплайн — доказанные циклы, DSM-бюджет, межфайловые повторы, форму sliced-dotnet.

curl -fsSL https://raw.githubusercontent.com/gently-whitesnow/harness-cli/master/install.sh | sh
Единственный внешний процесс git. Toolchain репозитория харнес не запускает и не инспектирует.
24 проверки Каждая явно перечислена в policy. Defaults в ридере нет — тихих фич нет.
Три кода возврата 0 прошло · 1 доказано нарушение · 2 проверить достоверно не удалось.
Принципы

Что харнес делает и чего не делает

Инструмент несёт общий стандарт формы, который иначе копировался бы между репозиториями. Специфичные для продукта контракты и архитектурные правила остаются в самом репозитории. Каждое правило ниже обосновано в ADR — реестр решений лежит в adrs/REGISTRY.md.

Только наблюдает

Не правит tracked-контент, не ставит toolchain, не меняет lockfile. Доказательство — только файл в git-индексе: untracked-файл для харнеса не существует, но отчёт называет его строкой not in the index, чтобы не молчать.

ADR-0008 ADR-0011 ADR-0026

Рамка self-reported

Репозиторий отвечает на вопросы рамки сам, в tracked .harness.json. paths — навигация, не доказательство. Харнес валидирует полноту и форму ответов, но не инспектирует их и не ищет опровержения в git.

ADR-0014

Тихих фич нет

Каждая shipped-проверка обязана быть в policy, каждый параметр — в settings, каждая языковая ось — в applicability. Отсутствующая запись — Incomplete с именем ключа, а не молчаливое включение с дефолтом.

ADR-0017 ADR-0035

Policy един для всех

required блокирует, advisory оставляет находки видимыми, off выключает — и это работает для любой проверки, включая топологический инвариант и ratchet-бюджет. Запрещено адресное подавление файла или находки: ни suppress, ни overrides.

ADR-0027 ADR-0035

Топология вместо порогов

Пороговые скоры (строки на файл, ветвления, cohesion) поддаются Goodhart-деградации и противоречат друг другу. Контракт 2.0 заменил их тремя ярусами: инварианты формы дерева, монотонные DSM-бюджеты, детерминированные policy.

ADR-0032 ADR-0033

Один контракт на бинарь

version в конфиге называет релиз и фиксирует весь контракт: какие вопросы задаются и какие проверки выполняются. Другой pin останавливает прогон с кодом 2; единственный путь его сменить — harness upgrade, который печатает маршрут миграции и не угадывает ответы.

ADR-0023
24 проверки · контракт 2.9.0

Каждая проверка названа, и каждая — в конфиге

Идентификатор — <семейство>.<язык>. Ось applicability выключает все проверки языка одной записью с причиной; policy решает судьбу каждой проверки отдельно. Второй язык — не копия проверки, а экземпляр: так comments.yaml и comments.typescript считают ту же плотность комментариев со своими settings.

Ниже — все проверки, которые ships бинарь 2.9.0, в порядке исполнения. Их же перечисляет harness help, их же требует ридер .harness.json: если хотя бы одной нет в policy, прогон завершается Incomplete и называет пропущенный ключ. Подробности каждой — harness explain <check-id>.

Глубже

Связанность, циклы, DSM и повторы: как это считается и почему именно так

Все структурные проверки питаются одним графом, построенным по tracked-исходникам лексическим ридером на BCL — без Roslyn, без build output, без запуска toolchain. Это осознанная цена NativeAOT-инварианта: инструмент измеряет меньше, чем компилятор, но никогда не завышает связанность на доказанных рёбрах. Диаграммы ниже интерактивны — рёбра и узлы можно двигать, чтобы увидеть, как меняется вердикт.

Граф зависимостей и уровни доказательности

Типы, объявленные в репозитории, образуют замкнутый словарь. Каждое имя в исходнике резолвится в объявление в том же порядке, что использует C#: свой namespace, затем объемлющие, затем импорты файла. Имя, не совпавшее ни с одним объявлением, — внешний импорт, а не догадка; имя с несколькими кандидатами — unresolved и в граф не попадает. Доля резолва печатается в отчёте.

Каждое ребро несёт оценку EvidenceGrade:

  • Proven — имя стоит там, где язык не допускает ничего, кроме типа: new, base list, тип параметра, поля, свойства или возврата, generic-аргумент, атрибут, typeof, sizeof, default, catch, либо тип, за которым следует вводимое им имя.
  • Inferred — имя совпало, но позиция ничего не доказывает: member access, аргумент, локальная переменная, приведение.

Blocking-находка строится только из Proven-рёбер. Тот же Proven-граф питает циклы модулей, инварианты sliced-dotnet и DSM-бюджет. Всё, что связывает компилятор — extension-методы, var, перегрузки, DI, рефлексия, — невидимо; связанность занижена, никогда не завышена.

Граница модуля. Имя модуля вложено в имя объемлющего: ссылка из Shop.Orders в Shop.Orders.Contracts — содержание, а не зависимость. Ребро поднимается до первого сегмента, которым имена расходятся: Shop.Orders.Api → Shop.Billing.Core записывается как Shop.Orders → Shop.Billing.

ADR-0021 ADR-0009
Один файл, две оценки рёбер Proven Inferred
namespace Shop.Orders;

public sealed class OrderService(IOrderStore store)
{
    private readonly Clock clock = new Clock();

    public Receipt Place(Cart cart)
    {
        var total = Pricing.Total(cart);
        if (cart.Lines is [])
        {
            throw new EmptyCartException();
        }

        return store.Save(cart, total, clock.Now());
    }
}
тип параметра, поля, возвратаProven: IOrderStore, Clock, Receipt, Cart
после new, typeof, catch, isProven: new Clock(), new EmptyCartException()
member access, аргумент, локальнаяInferred: Pricing.Total — имя совпало, позиция не доказывает
store, cart, totalне типы репозитория — рёбер нет
Proven-ребро Shop.Orders → Shop.Pricing не возникнет из строки с Pricing.Total: member access — только Inferred. Если Pricing нигде не стоит в типовой позиции, цикл через него не замкнётся.

Циклы модулей: единственная dependency-находка

Модули, зависящие друг от друга, нельзя упорядочить, вынести или переиспользовать в одном направлении. Это универсальный структурный дефект, который харнес умеет доказать по tracked-исходнику. Проверка становится blocking, только если выполнены сразу пять условий: детерминизм, actionable-находка, скорость, низкий риск false positive и негативная фикстура. Цикл по Proven-рёбрам проходит все пять.

Формула. Рёбра между типами схлопываются до рёбер между модулями (namespace), внутренние ссылки отбрасываются. Над графом модулей идёт алгоритм Тарьяна — итеративный, чтобы глубокий граф не съел стек. Каждая сильно связная компонента из двух и более узлов — цикл.

Что печатается. Компонента из одиннадцати модулей правдива и неисполнима; кольцо из двух — исполнимо. Поэтому внутри компоненты ищется кратчайшее кольцо (BFS от каждого узла) и печатаются строки исходника, которые его замыкают. Ребро-представитель выбирается по самой ранней локации, чтобы отчёт не менялся между прогонами.

Почему нет fan-in/fan-out. Пилот показал, что верх outgoing count закономерно занимают integration-тесты и composition roots, а высокий incoming count часто отмечает правильно выбранную стабильную абстракцию. У таких чисел нет универсального remediation, поэтому начиная с 1.5 они удалены; у цикла оно есть.

ADR-0002 ADR-0021 ADR-0029
Граф модулей · клик по ребру разворачивает зависимость
Компонент > 10
Кратчайшее кольцо
Вердикт при requiredpassed
Ребро Shared → … отсутствует намеренно: слой без исходящих рёбер не может войти в кольцо. Разверните любое красное ребро — компонента распадётся, а отчёт покажет следующее кольцо, если оно осталось.

DSM-сложность: mean reach и core size под бюджетом

Счётчик зависимостей описывает один файл. Design structure matrix описывает, как изменение распространяется по продукту целиком. Две метрики DSM-семейства сравниваются с одним tracked-потолком в .harness.budget.json: регресс блокирует, улучшение видно, пока репозиторий его не зафиксирует.

Mean reach — бюджетируемая величина. Пусть N — число измеренных файлов, R(i) — сам файл i плюс всё, что транзитивно достижимо из него по Proven-рёбрам.

mean reach = Σ|R(i)| / N

Это абсолютное число файлов, до которых доходит изменение типичного файла. Propagation cost (MacCormack, Rusnak, Baldwin, 2006) печатается рядом как справка для сравнения с литературой: Linux 5.16 %, Mozilla 17.35 % до редизайна и 2.78 % после.

propagation cost = 100 · Σ|R(i)| / N²

Почему не propagation cost. Знаменатель N² вознаграждал дробление и наказывал удаление: за неделю нарезки на слайсы в пилоте достижимых пар стало на 13 % больше, а cost упал на 14 %, потому что файлов стало на 15 % больше. Удаление изолированного файла всегда повышало cost. Mean reach от листьев не худеет и от удаления сдвигается не больше чем на долю файла.

Core size — размер крупнейшей циклической группы файлов (Baldwin, MacCormack, Rusnak, 2014). У файлов ядра ~3× плотность дефектов; ациклический граф даёт ядро 0.

core size = max |SCC| при |SCC| > 1

Область. Измеряются только файлы внутри архитектурных зон sliced-dotnet — тесты, тулинг и сэмплы вне зоны не входят. В пилоте тесты давали 21 % файлов и 56 % достижимых пар, а подсказки указывали на Mongo-фикстуру, а не на архитектуру. Граница читается из дерева, поэтому ни ответ рамки, ни список исключений не могут перенести файл через неё. Репозиторий без зоны измеряется целиком.

Вычисление. SCC-конденсация — DAG; достижимость считается битовыми наборами в обратном топологическом порядке, без рекурсии и внешних зависимостей. Бюджет — потолок: harness budget update только ужимает его; повысить можно лишь ручной tracked-правкой на ревью.

ADR-0032 ADR-0042 MacCormack 2006
Файловый граф · клик по файлу показывает R(i)
Файлов N0
Σ|R(i)|0
Mean reach0бюджет 0
Propagation cost0 %справочно
Core size0бюджет 0
Добавьте несколько листовых файлов: propagation cost падает, а mean reach почти не меняется — именно поэтому бюджетируется второе. Замкните цикл: появится ядро, и потолок coreSize будет превышен.

Дупликация: одинаковая лексическая форма в разных файлах

Одна и та же структура, выписанная в нескольких файлах, — самый дешёвый наблюдаемый признак того, что абстракция отсутствует или была скопирована вместо вызова. Проверка находит место и останавливается: лексическое совпадение — повод открыть два места, а не доказательство, что они значат одно и то же.

Нормализация. Каждый файл сводится к коду: комментарии, содержимое строк и символов, директивы препроцессора заменяются пробелами. Остаток превращается в последовательность токенов на каждую физическую строку по этим правилам и никаким другим:

комментарий, директиваничего
строковый литералодин токен ", что бы ни было внутри
символьный литералодин токен '
числоодин токен #
ключевое слово C#само себя — if и while никогда не совпадут
любое другое словоn — все идентификаторы читаются одинаково
прочий символсам себя
пробелы, пустая строкане выживают: переформатирование не влияет на совпадение

Единица сравнения — окно из windowLines подряд идущих нормализованных строк, несущее не меньше minimumTokens токенов. Окна ниже порога не сравниваются вовсе: строки из скобок и return одинаковы в каждом файле, и их никто не вынесет. Найденная группа растёт построчно назад и вперёд, пока все вхождения согласны, и репортится один раз как целый регион. Рост останавливается на строках, уже занятых другим повтором, — ни одна строка не отчитывается дважды. Повтор внутри одного файла не репортится.

Почему 30/90 и почему required. Ретроспектива на трёх репозиториях (1085 tracked .cs): профиль 8/24 дал 354 группы, 24/72 — 14, 30/90 — одну, 36/108 — ни одной. На пилотах 30/90 нашёл 12 и 6 пар до целевого рефакторинга и ни одной после; подъём окна до 36 скрыл бы пять из 18 подтверждённых случаев. Размер окна остаётся главным фильтром шума, а minimumTokens в диапазоне 72–150 почти не влиял. С 2.9.0 harness init пишет required; существующая явная policy при upgrade не переписывается.

Возможный вред. Извлечение общего хелпера из двух блоков, которые лишь выглядят похоже, связывает то, что не должно меняться вместе. Совпадение, не пережившее чтения, лечится настройкой детектора или advisory-policy, а не принудительной абстракцией. Архитектурный инвариант, циклы и DSM-бюджет независимо не дают лечить повтор общим модулем с неверным направлением зависимости.

ADR-0028 ADR-0007 ADR-0045
Нормализация и окно · демо на коротком профиле
OrdersRepository.cs

                
InvoicesRepository.cs

                
нормализовано

                
нормализовано

                
Демо-окно 2–8 строк вместо продуктовых 30/90, иначе на экран не поместится. Разные имена, литералы и числа сходятся к одним токенам; строка return null; меняет форму, и рост региона на ней останавливается.

sliced-dotnet/1: карта архитектуры — это сама файловая система

Контракт 2.0 задумывал per-repo декларации слоёв и модулей, как deptrac или import-linter. ADR-0033 отказался от них по примеру FSD и Steiger: словарь фиксирован методологией, линтер читает дерево и уже знает, что является нарушением. Осознанная правка декларации была бы легальным способом ослабить инвариант; изменить форму теперь можно, только изменив код. Секция architecture сводится к выбору из двух форм — стандарт или applicable: false с причиной для standalone-библиотеки.

Зона — каталог с Application/; монорепозиторий может содержать несколько зон, рёбра между ними запрещены. Слои — фиксированный словарь каталогов в корне зоны, DAG задан стандартом. СлайсыApplication/Features/<Слайс> с зеркалами в Api, Consumers, Infrastructure/Features и Domain. Публичный API слайса — его Contracts/; кросс-импорт между слайсами — только явный cross-API Contracts/X/<Потребитель> (перенос нотации @x из FSD 2.1), который вправе импортировать только названный потребитель. Слой = сборка: ровно один .csproj на слой с C#-кодом, ProjectReference — по той же таблице.

Слои и разрешённые рёбра

Host — composition root и единственное место, где контракты связываются с реализациями; видит все слои. Пунктир — запрещённое ребро, которое проверка называет по паре слоёв.

Таблица стандарта
СлойНазначениеРазрешённые рёбра
Hostcomposition root: Program, DI-wiring, конфигурацияво все слои
Apiсинхронный вход: controllers, endpoints, команды CLIApplication, Domain, Shared
Consumersасинхронный вход: очереди, подписки, hosted jobsApplication, Domain, Shared
Applicationuse-cases; слайсы в Features/Domain, Shared
Domainсущности; слайсы в корне, Shared зарезервированShared
Infrastructureадаптеры; слайсы в Features/, Persistence зарезервированApplication (только Contracts/), Domain, Shared
Sharedkernel, утилиты

Обязательны Host, Application и хотя бы один входной слой. Блокируют: DAG слоёв, изоляция слайсов, обход Contracts/, осиротевшее или пустое зеркало, цикл модулей, linked-компиляция чужого слоя. Advisory-observations без влияния на код возврата: плоский каталог 20+ файлов, взаимные и массовые X-контракты, essence-имена вроде Services или Helpers.

ADR-0033 ADR-0036 ADR-0037 ADR-0039 ADR-0041 FSD
Конструктор

Соберите .harness.json и убедитесь, что в нём есть всё

Сначала профиль репозитория — он выставляет оси применимости и архитектуру. Дальше общие проверки и языковые оси: проверка на неприменимой оси остаётся в policy и получает исход NotApplicable, а не исчезает из файла. Справа — файл целиком; счётчик внизу сверяет, что все 24 проверки, 4 оси, 8 ответов и все секции settings на месте — ровно то, что требует ридер.

Запуск

Установка, команды, CI

Скрипт определяет платформу, сверяет sha256 и кладёт бинарь в ~/.local/bin/harness без sudo и без .NET runtime. Той же командой харнес обновляется: пинить при установке нечего, потому что поведение задаёт проверяемый репозиторий, а не бинарь. Внутри репозитория с .harness.json скрипт заодно выполняет harness setup.

Команды
  • harness init --kind applicationсоздать явную рамку, бюджет и .editorconfig
  • harness check [--verbose] [--all]проверить репозиторий; --only / --skip по id или группе
  • harness explain <check-id>смысл, формула, пределы и исправление проверки
  • harness budget updateсоздать или ужать DSM-бюджет; повысить нельзя
  • harness upgrade [--dry-run]поднять pin и напечатать маршрут миграции
  • harness setupактивировать commit-шаблон и commit-msg hook в клоне
  • harness commits check a..bпроверить диапазон сообщений для CI
Пример отчёта
PASS  /srv/repo
  harness 2.9.0 · repository pins 2.9.0
   CHECK ID                     FINDINGS
✅ harness.config               .harness.json contains 8 answers, 24 explicit policy entries
✅ architecture.sliced-dotnet   architecture map: zone src/Harness · layers [Host, Api, Application, Domain, Infrastructure, Shared]
✅ complexity.csharp            mean reach 8.65 files · core size 0 files · scope: 112 files inside architecture zone
✅ docs.policy
✅ commits.setup
✅ comments.csharp
✅ dependencies.csharp          100% of the 1184 names that match a declared type resolved
✅ duplication.csharp
✅ build-properties.dotnet

⚠️ frame.tests.unit             repository answers absent — "ADR-0001: единственный тестовый шов — CLI"
✅ frame.verify                 repository answers present at verify.sh

Заголовок различает PASS, PASS WITH GAPS, NOTHING VERIFIED, FAIL и INCOMPLETE; причины и конкретные замечания раскрывает --verbose.

GitLab CI
harness:
  image: ghcr.io/gently-whitesnow/harness:2.9.0
  script:
    - harness check
    - harness commits check "$CI_MERGE_REQUEST_DIFF_BASE_SHA..$CI_COMMIT_SHA"
GitHub Actions
- name: Repository harness
  run: |
    curl -fsSL https://raw.githubusercontent.com/gently-whitesnow/harness-cli/master/install.sh | sh
    ~/.local/bin/harness check
0

Каждая выбранная применимая blocking-проверка отработала и прошла. Advisory-находки и readiness gaps при этом возможны — заголовок скажет об этом вместо PASS.

1

Выбранная применимая blocking-проверка доказала нарушение.

2

Проверку не удалось выполнить достоверно: отсутствующий или невалидный .harness.json, чужой pin, неполный policy, бюджет не найден.