Проверка границ в ежедневной работе
Правила линтера работают, пока их запускают. Эта статья о том, как встроить проверку архитектуры в сборку и разработку так, чтобы её нельзя было забыть, и как не дать проекту разрастись: примеры взяты из Projector, где FEOD обязателен во всех корнях.
Только правила FEOD, отдельно от остального линта
В проекте уже есть обычный линт и форматтер, а архитектурная проверка нужна как отдельный шаг: быстрый, с понятным результатом и без правил стиля. Projector запускает Oxlint, в который включены только правила FEOD:
// scripts/check-feod.mjs (сокращённо)
import { createConfig } from "@o-feod/oxlint-structure-plugin/configs";
import feodConfig from "../feod.config.mjs";
export function checkFeod({ rootDir, config = feodConfig } = {}) {
// собирает временную конфигурацию Oxlint из feod.config.mjs,
// запускает Oxlint только с правилами FEOD и возвращает диагностики
}Политика лежит в одном файле feod.config.mjs, а скрипт её читает. Благодаря этому правила одинаковы в терминале, в сборке и в редакторе.
Команда подключена в три места:
| Где | Поведение при нарушении |
|---|---|
build | блокирует сборку |
test | блокирует прогон тестов |
общий check | падает вместе с типами и тестами |
Отдельный запуск (architecture) нужен для быстрой проверки перед коммитом. Результат в успешном случае короткий: FEOD: boundaries, entries, cycles and size limits checked.
Проверка рядом с dev-сервером
В режиме разработки блокирующая проверка мешает: сервер должен подниматься, даже если сломан один импорт. Projector запускает её асинхронно, с таймаутом и без права остановить сервер:
// фрагмент идеи: вызывается на старте и при перезапуске dev-сервера
checkFeodAsync({ timeoutMs: 60_000 })
.then((diagnostics) => report(diagnostics))
.catch((error) => log.warn(error))Принципы:
- Нарушение или сбой Oxlint попадают в лог. Сервер продолжает работать: медленная или упавшая проверка стоит строки в логе, а не остановки разработки.
- Не на каждое HMR-изменение. Проверка идёт на старте и при перезапуске, иначе она съедала бы время на каждом сохранении. Постоянную подсветку даёт редактор.
- Блокирующая версия остаётся в сборке. Там нарушение должно останавливать процесс.
Если политика зависит от окружения (например, часть корней отключена в CI), конфигурацию собирают тем же скриптом, а не копируют.
Что исключать из проверки
Файлы вне заданных корней (tests, scripts, docs, конфигурации инструментов) не проверяются ни одним правилом: их не нужно перечислять в ignore (файлы вне корней). Список ignore нужен для сгенерированных файлов внутри корня:
rootDefault: {
ignore: ["dist/**", "public/**", "**/*.config.*", "packages/vio/tests/**"],
}Тесты, которым нужна приватная реализация (например, повторный импорт файла с query-суффиксом для проверки перезагрузки), живут вне корней. Исключений для таких случаев в боевом коде нет: так граница остаётся честной.
Реестр больших файлов
Размер файла хорошо сигнализирует о том, что в нём смешано несколько ответственностей. Правило large-files не просто ограничивает размер, а превращает долг в явный список:
extra: {
largeFiles: {
thresholds: { default: 400, ".vue": 500 },
files: {
"server/modules/agent/jobs.ts": { ceiling: 620, plan: "Вынести хранилище задач" },
},
},
}Правило работает в обе стороны. Два сообщения ниже получены на пробном проекте с порогом 25 строк (путь и план в записи реестра — условные):
server/modules/agent/big.ts has 30 lines, over the 25 limit. Split it, or register it in extra.largeFiles.files with a ceiling and a split plan
server/modules/agent/mid.ts is now 20 lines, within the 25 limit. Remove it from extra.largeFiles.files| Ситуация | Что говорит правило |
|---|---|
| Файл вырос выше порога, записи нет | разбейте файл или внесите его в реестр с потолком и планом |
| Файл в реестре вырос выше потолка | потолок поднимать нельзя: файл нужно сократить |
| Файл сократился относительно потолка | потолок нужно снизить |
| Файл вернулся в порог | запись нужно удалить |
| Файл удалён | запись нужно удалить |
Остальные строки таблицы описаны в документации Projector и на пробном проекте не воспроизводились. Реестр работает как «храповик»: долг можно держать, но нельзя увеличивать, а уменьшение закрепляется. Таблицу для документации можно генерировать тем же счётчиком (largeFilesRows, renderLargeFilesMarkdown), чтобы список файлов и планов не расходился с конфигурацией (реестр больших файлов).
Считаются строки, которые видит линтер: для .vue это блок <script>.
Настройки, которые окупаются на практике
В Projector включено почти всё, что есть в конфигурации. Вот что и зачем:
| Настройка | Зачем |
|---|---|
imports.computed: "forbid", imports.unresolved: "forbid" | нет невидимых для линтера зависимостей и опечаток в путях |
cycles: { modules: true, types: "include" } | циклы ловятся и в типах, а не только в значениях |
modules.publicEntries: ["index.ts"], requirePublicEntry: true | у каждого модуля один явный вход |
modules.forbiddenNames: ["common", "shared", "utils"] | не появляются модули-свалки с размытым смыслом |
submodules.visibility: "parent-only" | подмодуль виден только родителю |
submodules.childImportsParent: "implementation" | подмодуль может прочитать типы родителя без ложного цикла parent → child → parent |
globals: { allowedFiles: ["**/*.d.ts"], localImports: "forbid" } | глобальный слой остаётся набором деклараций без зависимостей |
inventory.uncovered: true | файлы вне слоёв находятся, даже если их никто не открывал |
transitModules по корням | сквозные контейнеры объявлены там, где они есть |
Параметр childImportsParent решает типичную проблему: подмодулю нужны типы родителя, но с cycles.types: "include" такой импорт выглядел бы циклом. Политика разрешает ребёнку читать реализацию непосредственного родителя и не считает это циклом, а зависимости значений и циклы между модулями по-прежнему проверяются.
Как не дать проверке стать формальностью
- Одна команда на всё. Команда
checkзапускает архитектуру, тесты, типы и сборку. Если архитектура отдельно, её пропустят. - Документ рядом с политикой. Описание корней и ограничений (
docs/architecture.md) хранится в репозитории, а вAGENTS.mdесть строка «FEOD обязателен во всех корнях». Новый участник или ИИ-агент видит правило раньше, чем нарушает его. - Нарушение объясняет себя. Поле
messageвconfineпревращает «import is confined» в понятную фразу: «Linux-specific operations live in the Linux adapter». - Исключения видны. Всё, что не проходит проверку, либо внесено в
ignore, либо в реестр больших файлов, либо вallowedIn. Это три места, где можно просмотреть накопленный долг.
См. также: одно ядро для нескольких приложений, связанные модули, FEOD для бэкенда.