Перейти к содержимому

validate.yml

Проверки готовности проекта.

workspace/validate.yml объявляет проверки готовности уровня проекта. CLI использует их из двух точек входа:

  • dwe validate — запускает каждую проверку (плюс YAML-shape валидаторы в доменах config, templates, commands, translations и bridge, плюс environment-probe’ы в домене env) и выводит диагностику.
  • Хук preflight в dwe deploy run, dwe run, dwe stop, dwe restart и dwe reset run — запускает подмножество проверок, связанных с соответствующей стадией, до любого побочного эффекта на Docker, git или файловую систему.

Цель — заранее показать проблемы, которые пользователь может починить («вы не залогинены в ghcr.io», «DATABASE_URL пуст в .env», «VPN лёг») ДО того, как шаги деплоя упадут на середине с непонятными ошибками.

Команда validate запускает семь доменов в дополнение к существующим YAML-shape валидаторам:

ДоменИсточникНастраивается?
env.*Жёстко зафиксировано в CLIНет — семь фиксированных probe’ов
checks.*Записи workspace/validate.ymlДа — декларативно
secrets.*Recipient блока secrets:, маркеры ENC[age:…] в слоях, источники паков *.ageНет — три фиксированных валидатора
linters.*Встроенные адаптеры (shellcheck, hadolint) + блок linters: в workspace/validate.ymlДа — декларативно
translations.*Файлы переводов workspace/i18n/Нет — фиксированные валидаторы (ошибки парсинга, осиротевшие id команд/групп, неизвестные ключи render.*)
snapshot.*Директории снапшотов на диске + workspace/snapshot.ymlНет — фиксированные валидаторы на каждое имя снапшота
tests.*Файлы сценариев workspace/tests/*.ymlНет — фиксированные валидаторы сценариев (рендерят и резолвят шаги каждого сценария, флагают неизвестные сервисы / ссылки на команды / дублирующиеся имена шагов и показывают риски compose-изоляции как предупреждения); также предупреждают, когда host-скрипт или shell-шаг сам собирает имя compose-проекта, а не выводит его из $COMPOSE_PROJECT_NAME

Домен tests.* работает только в validate (как и snapshot.*) — он никогда не запускается в preflight и молчит, если workspace/tests/ отсутствует. Полный набор проверок сценариев (dwe validate tests) см. в tests.md.

У домена secrets.* три валидатора, и они разделены по линии «контент / готовность», которая и определяет, что попадает в preflight (см. Стадии):

ВалидаторВидСрабатывает, когда
secrets.recipientконтент — только dwe validateЕсть маркеры или источники .age, но secrets.recipient отсутствует либо не является корректным age1…; либо payload маркера повреждён (плохой base64 / не age-файл)
secrets.unresolvedготовность — работает и в preflightХоть одно зашифрованное значение в объединённом конфиге не расшифровалось здесь, либо источник .age резолвящегося конфиг-пака не расшифровывается загруженным identity
secrets.shadowedэффективность — только dwe validate, предупреждениеМаркер перебит плейнтекстом из вышележащего слоя, поэтому зашифрованное значение никогда не читается

secrets.recipient сам грузит файлы слоёв, когда конфиг не загрузился, поэтому scoped-запуск dwe validate secrets всё равно диагностирует некорректный recipient, а не остаётся слепым. Повреждённый payload определяется вообще без ключа — поэтому он относится к контент-валидатору: разработчика без ключа никогда не отправляют искать ключ, который всё равно бы не помог.

secrets.unresolved — второе исключение из правила, что в preflight попадают только env.* и checks.* (первое — config.validate). Это вопрос готовности — «может ли эта машина реально деплоить прямо сейчас», — а не контента, поэтому dwe run / dwe restart / dwe stop / dwe deploy run / dwe reset run и pre-flight-гейт мастера деплоя останавливаются с одной и той же понятной подсказкой (per-service dwe stop|restart <имя> намеренно обходят и compose-оверлей, и preflight), а dwe status, dwe docs, dwe validate и dwe prompt продолжают работать. Нерасшифрованные значения группируются по причине: одна диагностика на причину со списком отсортированных путей, чтобы единственное осмысленное действие не было похоронено под одинаковыми строками на каждый маркер. Причины: no_identity (источника нет нигде), invalid_identity (источник задан, но ключа age не содержит — обрезанный DWE_AGE_KEY, keyfile, потерявший строку с ключом; сообщение называет источник и составлено из фиксированных формулировок, потому что ошибка парсера age повторяет байты приватного ключа, на которых он споткнулся), wrong_identity (читаемый identity другого recipient) и corrupt (повреждённый payload — проблема не в ключе). В интерактивном dwe run / dwe restart / dwe deploy отсутствующий identity предлагается ввести до preflight, поэтому сюда доходит только то, чего предложение не покрывало: неинтерактивный запуск или сломанный источник, который импорт ключа всё равно не починит. Отказ от предложения сюда не доходит — команда завершается прямо на гейте с той же подсказкой.

Скан источников .age повторяет то, что реально обходит render config, поэтому выключенный сервис или нерезолвящийся пак невидимы валидатору ровно так же, как и во время рендера. Модель и команда dwe secrets status, которая сообщает то же самое, ничего не блокируя, — в secrets.md.

secrets.shadowed сообщает о маркере, перебитом плейнтекстом из вышележащего слоя: он отлично расшифровывается и числится читаемым, а проект при этом читает плейнтекст — то есть пара ключей не является тем, через что это значение шарится. Это предупреждение, а не ошибка, и в preflight валидатор не попадает: временно перебить общий секрет локальным значением — законный приём.

Находки группируются по перебивающему файлу и вердикту — ровно то, от чего меняется способ починки:

ВердиктЧтениеПодсказка
secrets.shadowed:identicalПеребивающее значение совпадает с маркеромПочти всегда копия, оставшаяся после переезда значения в шифр — её надо удалить, маркер уже хранит то же содержимое
secrets.shadowed:differentПеребивающее значение отличаетсяОсознанный локальный оверрайд; удалите его, чтобы вернуться к общему зашифрованному значению
secrets.shadowed:unknownМаркер здесь не расшифровался, значения не сравнивалисьНачинается с идентичности: пока плейнтекст прикрывает маркер, потерянный ключ больше нигде в повседневной работе не проявится

Сравнение даёт только вердикт — ни одна из сторон в отчёт не попадает. Маркер, перебитый другим маркером, находкой не является: выигравшее значение по-прежнему зашифровано на диске. Эффективное значение считается тем же merge, что и в загрузчике, поэтому вышележащий слой, заменяющий всё родительское поддерево маркера (скаляром, списком), тоже отлавливается.

Все три валидатора секретов выдают строку ✓, когда не нашли проблем, поэтому здоровый проект сообщает validation result: 3 checks, а не validation skipped (no files found) — именно это делает dwe validate secrets пригодным как самопроверка после онбординга ключа. Строке recipient достаточно корректного secrets.recipient, и она идёт без сообщения; строка unresolved появляется, только когда есть что читать и не возникло ни одной диагностики, и считает инвентарь (N encrypted value(s) and M config-pack source(s) readable via <источник>, где источник назван словом — env / env-file / keyfile — и никогда путём); строка shadowed появляется, только когда в проекте есть маркеры, и считает их (N encrypted value(s), none shadowed by a plaintext override) — именно это позволяет зелёному прогону означать «пара ключей и есть то, через что шарятся эти значения», а не только «маркеры расшифровываются». Проект без блока secrets: и без шифрованных значений остаётся молчаливым. Preflight и гейт мастера деплоя отфильтровывают строки SeverityOK, поэтому ни один из них не печатает ничего лишнего.

Probe’ы env.* это: env.docker_bin, env.docker_daemon, env.docker_compose, env.git_bin, env.shell_bin, env.project_perms, env.ports_free. Они запускаются на каждом вызове dwe validate и на каждом preflight (независимо от стадии — у env нет понятия стадии), с одним исключением: env.ports_free пропускает себя на стадии stop, поскольку конфликты портов нерелевантны при сворачивании проекта.

env.ports_free читает каждый хост-порт, объявленный в services.<name>.ports (только enabled-сервисы), и проверяет, можно ли каждый забиндить. Он один раз опрашивает docker ps --format=json, чтобы узнать, какие контейнеры держат какие порты сейчас: контейнеры с лейблом com.docker.compose.project=<наш проект> трактуются как «наши» (compose переиспользует их при up); контейнеры из любого другого compose-проекта вызывают диагностику конфликта с указанием чужого контейнера и проекта; для портов, не удерживаемых ни одним контейнером, probe откатывается к прямой попытке привязки, чтобы обнаружить не-Docker процессы. Недоступность Docker проходит молча — env.docker_daemon покрывает этот случай.

При per-service деплое probe сужается до портов, которые этот запуск реально может забиндить: dwe deploy run --service <name> (и деплой, который выполняет dwe services enable|disable --apply) проверяет только названные сервисы плюс транзитивное замыкание depends_on из их service.yml — то, что поднимает docker compose up <name>. Чужой контейнер, держащий порт другого сервиса, больше не блокирует запуск, который этот порт не трогает. Запуски на весь проект (dwe deploy run, dwe run) и dwe validate не изменились: они проверяют каждый enabled-сервис. Замыкание идёт только по depends_on из service.yml, поэтому зависимость, объявленная исключительно в голом compose, не учитывается — она проявится ошибкой привязки compose внутри шага.

config.compose_project_name предупреждает, когда активная цепочка compose -f объявляет верхнеуровневый name:, отличающийся от имени проекта, которое dwe передаёт через docker compose -p. dwe всегда вызывает compose с -p <resolved> (разрешённый project_name из workspace/docker.yml, иначе каноническое <prefix>-<name> из project.name), и этот -p молча переопределяет любой верхнеуровневый name: в compose-файлах. Поэтому расходящийся name: — это мёртвая конфигурация: реальная область проекта (лейблы контейнеров/сетей/томов) равна разрешённому имени dwe, а не тому, что как будто объявляет файл — и грабли для всех, кто запускает голый docker compose без -p от dwe. Поскольку .env теперь несёт COMPOSE_PROJECT_NAME (см. render env → Системные переменные), голый compose из корня проекта и так следует имени dwe — расходящийся name: не действует даже в этом случае, а ресурсы, созданные под ним, становятся невидимы для голого compose. Проверка моделирует реальную приоритетность compose: она проходит активную цепочку (ComposeFiles() — только включённые оверлеи) в порядке -f и сравнивает последний объявленный name: (более поздний -f переопределяет более ранний), поэтому базовый name:, уже исправленный более поздним оверлеем, не вызывает предупреждения. Чинится приведением к одному значению: поменять эффективный compose name: на совпадающее, задать project_name в docker.yml и убрать ставший лишним compose name:, либо вписать в compose-файл name: ${COMPOSE_PROJECT_NAME} — теперь оно резолвится из сгенерированного .env. Проверка молчит, когда эффективное имя использует неразрешённую интерполяцию (например, name: ${COMPOSE_PROJECT_NAME} — сравнить нельзя) или когда project.name не задан (dwe опускает -p, и compose использует собственный name: файла).

config.formal_block_fields предупреждает, когда формализованный верхнеуровневый блок конфига несёт неизвестный вложенный ключ. Смерженный трёхслойный конфиг декодируется нестрого (обычный yaml.Unmarshal, без KnownFields), поэтому опечатка под одним из этих блоков — например, stop: { port_release_timeot: 0 } — молча отбрасывается, и поле проваливается к своему дефолту, незаметно меняя поведение. Строгий корневой allowlist ловит неизвестные ключи верхнего уровня жёсткой ошибкой, но не вложенные; эта проверка закрывает пробел нефатальным предупреждением dwe validate (она не запускается в preflight, поэтому никогда не блокирует dwe run). Она сканирует все три слоя (workspace.yml, workspace/defaults.yml, workspace/local.yml) и привязывает каждую находку к файлу и строке. Набор распознаваемых ключей для каждого блока выводится рефлексией из YAML-тегов соответствующей Go-структуры, поэтому он не может разойтись при добавлении поля в структуру. Покрытые блоки (только непосредственные дочерние ключи — проверка не спускается во вложенные структуры вроде runtime.spx): project (name, prefix), runtime (use_https, spx), exports (env), compose (base, extra), docs (mermaid, cache_size_mb), update (mode), bridge (vars_writable), stop (port_release_timeout), secrets (recipient). Свободные (vars) и per-service (services) блоки намеренно исключены.

config.template_refs предупреждает о ссылке ${head.path}, чей head является разрешённым корневым ключом смерженного конфига, но оставшийся путь не резолвится — почти всегда это опечатка, поскольку vars: — единственный корневой ключ с полностью свободным содержимым (${vars.opechatka}, когда объявлен только vars.source.repo). Head сверяется со списком разрешённых корневых ключей, а не с тем, что проект реально объявил, поэтому ${vars.source.repo} в проекте вообще без блока vars: тоже будет отмечен. Проверка намеренно молчит на нераспознанном head’е (${HOME}, случайный знак доллара) и на специальных неймспейсах, которые никогда не живут в Raw (param, context, files, host, snapshot, args, generated) — см. Два синтаксиса: shorthand и полные шаблоны в справочнике по шаблонам.

Область проверки не ограничивается шагами пайплайна, но и не покрывает все поля подряд: проверяются скалярные поля, которые varsusage считает шаблонизируемыми, по всему workspace-YAML — cmd, text, value, title, project_name, confirm, скалярный when:, timeout шага и собственный command у files_gate, argv_append_from команды, плюс каждый скалярный лист (на любой глубине) под маппингом with: или env: и каждый элемент последовательностей argv: / compose_args: команды — плюс тела рендер-шаблонов под workspace/templates/config/**. Скалярные поля вне этого набора (workdir, messages.*, confirmation_text) не сканируются, поэтому ${vars.typo} в них не будет отмечен. Проверяется только shorthand-форма ${...} — Go-шаблонная форма ({{ resolve .Raw "vars.x" }}) не проверяется.

config.exports предупреждает о правиле exports.env, чей dot-path в from: или when: не резолвится в смерженном конфиге. Это голые dot-path’ы, а не ссылки ${...}, поэтому config.template_refs их не видит: from: vars.db.passwrod — корректная строка, загрузчик её принимает, а dwe render env рендерит DB_PASSWORD= — пустое значение, которое доходит до каждого контейнера так, будто его так и объявили. Нерезолвящийся when: ещё тише: он ложный, поэтому правило пропускается и переменная не появляется вовсе — и раз правило мертво, его from: не репортится вторично. Подсказка называет последствие для конкретной формы правила — переменная рендерится пустой, всегда используется default:, или dwe render env падает на правиле с required:. Гейт читается первым, как его читает рендерер: если when: резолвится, но ложный, правило пропускается ещё до резолва from:, поэтому подсказка вместо этого говорит, что гейт сейчас ложный и промах проявится, когда гейт включится. Это предупреждение, а не ошибка: from: вместе с default: — легитимный паттерн необязательного пути, а сам путь может жить в local.yml, которого нет на этой машине. Путь, который резолвится в существующий ключ со значением null, не репортится — ключ есть, значит пустой рендер осознан автором. Находка привязывается к слою, объявившему правило (workspace.yml, workspace/defaults.yml или workspace/local.yml).

Домен commands применяет тот же критерий к трём dot-path полям в workspace/commands/*.yml: params.<name>.default_from, params.<name>.options.from и context.<name>.from. Каждое предупреждает, когда путь не резолвится в смерженном конфиге — нерезолвящийся default_from оставляет параметр без значения по умолчанию, нерезолвящийся options.from — с пустым списком вариантов, а нерезолвящийся context.from рендерит ${context.<name>} пустой строкой внутри тела команды. Файлы команд проверяются в том виде, как написаны, поэтому синтетические команды, развёрнутые из daemon-блока, никогда не репортятся дважды.

Тот же домен проверяет hide: на командах и в блоках group:. Помимо синтаксиса шаблона, каждое выражение рендерится против смерженного конфига — того же контекста, что использует runtime-видимость, — и ошибка рендера даёт предупреждение hide: expression does not evaluate: …. Runtime-вычисление fail-open, поэтому иначе такое выражение оставляет команду видимой, оставив лишь строку в логе. Затем результат рендера должен быть тем, что runtime умеет вычислить: литерал true / false / 1 / 0 или пустая строка, cmd: <shell-команда> или when:-предикат с аргументами (dir-exists <path>, generated-missing <svc> <field>, …). Всё остальное — yes, опечатка в глаголе вроде dir-exist, file-exists без пути — даёт предупреждение hide: expression renders to "…", which is neither a boolean nor a known predicate; в runtime такое выражение тоже оставило бы команду видимой. Отрендеренный cmd: или builtin-предикат dwe validate не исполняет (сообщается только пустой cmd: — как hide: expression renders to an empty `cmd:` command; в runtime это ошибка вычисления); при неудачной загрузке конфига проверка рендера пропускается. Проверяется только результат для текущего конфига: ветку шаблона, в которую этот конфиг не заходит, проверка не видит. См. Условие hide.

Тот же домен проверяет цель команд service_exec, service_run и daemon. Runtime передаёт service: (или runner.service:) прямо в docker compose exec / run, поэтому там должно стоять имя compose-сервиса, а не ключ dwe-сервиса. Проверка даёт предупреждение service: "<name>" is not a service in any compose overlay, disabled services included, если ни один compose-файл под git не объявляет такой сервис. Это compose.base и файлы из compose: и compose_after: каждого сервиса, включая выключенные, поэтому команда, нацеленная на compose-only сервис выключенного сейчас инструмента, не считается ошибкой. Локальные оверлеи не учитываются: compose.extra из local.yml (общепроектный и посервисный) и сгенерированный bridge-оверлей у каждой машины свои, а файл команд общий, поэтому ответ одинаков в любом чекауте, а сервис, который объявлен только в локальном оверлее, считается ошибкой. Подсказка предлагает ближайшее compose-имя, а если значение является ключом dwe-сервиса, то compose-сервис, под которым этот ключ запускается. Если отслеживаемые файлы не объявляют ни одного сервиса, подсказка говорит об этом. Проверка пропускается, если значение шаблонное (${param.*}, {{ … }}), если конфиг не загрузился и если какой-то файл набора не читается, не разбирается или подключает другие файлы через include: верхнего уровня. Отсутствующий или нечитаемый файл набора сообщает config.compose_files, а файл, который не разбирается, — нет.

config.container_name предупреждает, когда container_name: compose-сервиса расходится с общепринятым <project>-<service> — именем, которое напрямую строят daemon-builtins и на которое по привычке полагаются скрипты и документация. Дефект — само расхождение, а не регистр. Собственные per-service команды dwe (dwe stop/restart/logs <name>) находят контейнеры по compose-меткам project+service, а не угадыванием этого имени, поэтому на них расхождение не влияет; грабли — голое использование docker/docker compose, скрипты и документация. Обратите внимание: удалить container_name — не то же самое, что выровнять его: compose тогда назовёт контейнер <project>-<service>-1. Проверка молчит, когда объявленное значение уже совпадает, или когда это интерполированное значение ${...}, которое нельзя сравнить без разрешения окружения.

config.compose_files предупреждает, когда файл из compose: или compose_after: сервиса не существует (или является каталогом); так же проверяется compose.base, с которого начинается любая цепочка -f. Без этой проверки опечатка всплывает только ошибкой docker compose при следующем dwe run. Пути разрешаются так, как их получает compose, — от корня проекта, а не от папки сервиса; если отсутствующий путь существует относительно папки сервиса, подсказка называет этот файл. Проверяются все сервисы независимо от того, включены ли они: операции compose с --all загружают файлы выключенного сервиса, а его последующее включение сломало бы стек. Путь, унаследованный дочерним сервисом через extends:, сообщается один раз с перечислением всех сервисов, где он указан. Это предупреждение, а не ошибка: compose-файл может лежать в исходниках, которые клонирует dwe deploy run, и в свежем чекауте его может не быть до первого деплоя. Персональные пути compose.extra из local.yml здесь не проверяются: отсутствующий общепроектный файл, как и файл включённого сервиса, уже отвергает LoadConfig.

config.healthcheck_start_period — информационная заметка о compose-сервисе из активной цепочки -f (только включённые сервисы), чей healthcheck выполняет test, но не объявляет start_period. dwe run поднимает стек через docker compose up --wait, и без start_period каждая неудачная проверка медленно стартующего сервиса во время загрузки засчитывается в retries — при значениях по умолчанию (interval: 30s, retries: 3) сервис, которому нужно больше времени, помечается unhealthy, и весь запуск падает. Healthcheck сливается по цепочке так же, как это делает compose, — по ключам: start_period из более раннего файла по-прежнему учитывается, !reset удаляет healthcheck, !override заменяет его. Проверка молчит, если healthcheck выключен (disable: true, test: ["NONE"]) или не объявляет test:; явный start_period: 0s считается объявленным. Healthcheck, зашитый в образ, не виден. Это info, а не предупреждение: большинство сервисов успевают стартовать за interval × retries, заметка называет возможный сценарий отказа, а не дефект, поэтому --strict из-за неё не падает.

config.ports_exports предупреждает, когда сервис объявляет services.<name>.ports.<key>, но ни одно правило exports.env не читает services.<name>.ports.<key>. Такой порт — только для отображения: его переопределение в local.yml никак не сдвинет реальную привязку контейнера, и автоматическая изоляция host-портов dwe test (см. tests.md) на него тоже молча не распространяется. Сервис, не объявляющий собственных портов, наследует всю карту портов родителя через extends:; такие унаследованные порты повторно на ребёнке не репортятся — находка принадлежит service.yml родителя, где порт реально записан.

config.info сообщает об эффективном состоянии workspace/info.yml, а не просто о том, «существует ли он»: файл, состоящий только из комментариев или пустой, трактуется так же, как отсутствующий — молча активен встроенный дашборд — и репортится с SeverityInfo (а не SeverityOK, чтобы агент, сканирующий на зелёный статус, не прекращал искать); намеренный sections: [] репортит своё собственное состояние с SeverityInfo; только авторский дашборд с реальным содержимым получает SeverityOK.

config.deploy, config.lifecycle и config.reset применяют ту же трёхсостоянийную модель к файлам пайплайнов. Отсутствующий workspace/deploy.yml / workspace/lifecycle.yml репортится как no <file> — built-in default pipeline is active с SeverityInfo. Файл, состоящий только из комментариев или пустой — то состояние, которое создаёт dwe init, — репортится как <file> has no active content (all comments or empty) — built-in default pipeline is active, тоже с SeverityInfo: файл существует, но реально выполняется встроенный дефолтный пайплайн, поэтому SeverityOK заявлял бы о пайплайне, которого нет в силе. Файл, который парсится, но не несёт пайплайна, — тот же случай в другой обёртке и тоже репортится с SeverityInfo: deploy.yml / reset.yml с пустым или отсутствующим phases: даёт <file> declares no phases — built-in default pipeline is active, а lifecycle.yml без одной из половин — lifecycle.yml declares no run: section — built-in default run pipeline is active (аналогично для stop:, по одной строке на каждую отсутствующую секцию). SeverityOK получает только файл, содержимое которого действительно доходит до движка. Кросс-проверка плана (plan resolution failed: …) выполняется в любом состоянии, поскольку разрешённый план — это ровно то, что выполняет deploy.

Для deploy.yml и reset.yml у этих двух Info-состояний есть прямое лекарство: dwe deploy eject --out workspace/deploy.yml --force (или dwe reset eject --out workspace/reset.yml --force) записывает встроенный дефолт поверх инертного файла, превращая его в настоящий редактируемый пайплайн — без --out документ уходит в stdout и ни один файл не трогается. --force здесь обязателен, потому что инертный файл уже существует: eject отказывается перезаписывать любой существующий файл без него, включая авторский пайплайн. Сообщение отказа опирается ровно на те же два условия, что и этот валидатор, — оно называет файл инертным только тогда, когда он действительно инертен, — так что обе команды всегда согласны в том, какие файлы инертны. У lifecycle.yml аналога eject нет — см. deploy/index.md.

templates.ai / templates.ide / templates.git предупреждают об отсутствующем template-паке только после того, как сервис явно выставил render.<kind>.enabled. Сервис type: app на неявном значении по умолчанию, у которого пака на диске нет, — это состояние после скаффолда, а не дефект, и он молчит. templates.git применяет то же правило к своему уведомлению «нет src/.git» — репозиторий может быть наполнен deploy-шагом ещё до того, как отработает render.

templates.workspace проверяет весь список render.workspace совместно через план workspace-паков. Без записи файлов проверяются имена и дубликаты, манифесты, источники, выполнение .tmpl, защищённые назначения, коллизии между паками и безопасность файловой системы. Отсутствующий пак из списка — ошибка; отсутствующий или пустой список даёт info-диагностику. dwe validate templates workspace запускает только этот валидатор. В preflight он не участвует.

config.reset — единственное исключение из абзаца выше в части отсутствующего файла: при отсутствии workspace/reset.yml он молчит полностью. В отличие от deploy.yml / lifecycle.yml, этот файл никогда не создаётся скаффолдом, поэтому его отсутствие — универсальное состояние почти любого проекта, а не намеренный отказ. Если reset.yml всё же есть, но состоит только из комментариев или пуст, он репортится так же, как два других.

Валидаторы checks.* создаются по одному на каждую запись validate.yml. Каждый в рантайме диспатчится либо во встроенную процедуру инспекции, либо в изолированную пользовательскую команду.

checks:
- id: ghcr-login
description: Authenticated against ghcr.io
stages: [deploy]
severity: error
hint: |
Run `docker login ghcr.io` with a personal access token.
type: builtin
cmd: shell
with:
cmd: docker pull ghcr.io/owner/repo:latest >/dev/null
- id: project-deps
description: Project dependency check script passes
stages: [run, deploy]
type: command
cmd: deps.check

Файл опционален. При его отсутствии запускаются только env.* и существующие YAML-shape валидаторы.

ПолеТипОбязательноеОписание
checkslistнетЗаписи проверок (см. ниже). Опционально; может быть пустым или опущенным.
lintersmapнетАдаптеры внешних линтеров (см. Внешние линтеры).

Неизвестные поля верхнего уровня отвергаются на загрузке (строгое декодирование).

ПолеТипОбязательноеОписание
idstringдаУникальный идентификатор. Становится Target в диагностике.
descriptionstringдаЧеловекочитаемое summary, показываемое в таблице диагностики.
stageslist of stringsдаСтадии, на которых срабатывает эта проверка (см. Стадии).
serviceslist of stringsнетОграничивает проверку проектами, где включён хотя бы один из перечисленных сервисов (см. Привязка к сервисам).
typestringдаОдно из builtin или command. Неизвестные значения отвергаются.
cmdstringдаИмя билтина (для type: builtin) или ID пользовательской команды (для type: command).
severitystringнетОдно из error (по умолчанию), warning, info. Неизвестные значения отвергаются.
hintstringнетПодсказка по устранению, включаемая в диагностику. Держите её краткой; длинные подсказки разбивайте через \n.
withmapнетПараметры, передаваемые билтину или пользовательской команде.

Правила схемы, проверяемые на загрузке:

  • id должен быть уникальным среди записей.
  • stages должен быть непустым.
  • services должен быть непустым, если ключ задан (опустите ключ целиком, чтобы запустить проверку безусловно). Пустые строки внутри списка отвергаются.
  • Неизвестные имена сервисов (опечатки вида services: [aap], когда есть только api) всплывают как error-диагностики config.validate в рантайме, не как load-ошибки — у загрузчика нет доступа к merged-карте сервисов.
  • type должен быть builtin или command.
  • severity должен быть error / warning / info, если задан.
  • Валидность формы with: (обязательные ключи, типы) проверяется методом Validate целевого билтина — падения всплывают как диагностики checks.<id> в рантайме, не как load-ошибки.

Проверка запускается всякий раз, когда её список stages содержит стадию, запрошенную вызывающим. CLI определяет пять зарезервированных стадий с встроенными хуками:

СтадияТриггеры
deploydwe deploy run, dwe validate --stage deploy
rundwe run, dwe restart (нога run), dwe validate --stage run
stopdwe stop, dwe restart (нога stop), dwe reset run, dwe validate --stage stop
commanddwe validate --stage command (зарезервировано на будущее; автоматического хука нет)
post-setupтолько финальный preflight деплоя — dwe deploy run, dwe deploy после setup-визарда, dwe validate --stage post-setup

dwe validate без --stage запускает каждую проверку независимо от стадии.

deploy vs post-setup: когда в потоке деплоя запускается проверка

Заголовок раздела «deploy vs post-setup: когда в потоке деплоя запускается проверка»

У потока деплоя два момента preflight:

  1. Ранний pre-wizard gate (только в интерактивном меню dwe deploy, до показа setup-визарда) — всплывает на проблемах вроде упавшего демона Docker раньше, чем пользователь вложит время в заполнение визарда.
  2. Финальный preflight, запускаемый прямо перед выполнением пайплайна деплоя — и в dwe deploy run, и после визарда в интерактивном dwe deploy.

Проверки stages: [deploy] запускаются на обоих моментах. Для проверки, зависящей от значения, которое визард пишет в local.yml, это неправильно: на раннем gate значение ещё не задано, поэтому проверка блокирует пользователя до того, как он доберётся до визарда.

Проверки stages: [post-setup] запускаются только на финальном preflight — после того, как визард заполнил local.yml, либо (когда визарда нет, например dwe deploy run) прямо перед пайплайном. Это правильная стадия для гардов «значение должно быть задано перед деплоем»: интерактивный визард его заполняет, а неинтерактивный путь всё равно ловит незаданное значение до любого побочного эффекта, а не падает посреди пайплайна. Сочетайте с config_keys_present для проверки значений смерженного конфига или с env_keys_present для отрендеренных .env-файлов.

Проверка post-setup не несёт стадию deploy, поэтому естественным образом пропускается на раннем gate. (stages: [deploy, post-setup] принимается, но избыточно — ведёт себя ровно как [deploy].) Вне потока деплоя post-setup смысла не имеет; на dwe run/dwe stop она никогда не срабатывает.

Неизвестные стадии принимаются (открытое перечисление), но производят предупреждение на загрузке, чтобы пользователи ловили опечатки рано:

  • stage "deplooy" is not a known preflight stage (с предложением, если близко по расстоянию Левенштейна)
  • Особые заметки: restart композитный (использует обе стадии stop и run, отдельного preflight нет); reset использует только стадию stop

Неизвестные стадии всё ещё можно вызвать явно через dwe validate --stage <name>, если нужно (например, для кастомных workflow’ов валидации).

services: ограничивает проверку проектами, где включён хотя бы один из перечисленных сервисов. Семантика — ИЛИ:

  • Поле отсутствует → проверка запускается всегда (когда сматчилась стадия).
  • services: [api] → проверка запускается только если включён api.
  • services: [api, worker] → проверка запускается, если включён api ИЛИ worker. Все сервисы выключены → проверка тихо пропускается (никакой строки в диагностической таблице).

services: и stages: — независимые AND-фильтры: сначала проверяется стадия, затем сервисы. Проверка с stages: [deploy] и services: [api] запускается, только когда оба условия выполнены.

Поведение идентично в preflight и dwe validate (без флагов и без переменных окружения). Один escape hatch: dwe validate checks <id> с явным id отключает services-гейт, чтобы можно было инспектировать проверку, все целевые сервисы которой выключены — полезно при отладке самого гейта.

Неизвестные имена сервисов производят error-диагностику в таргете config.validate, чтобы опечатки всплывали рано; саму проверку с gate-ом в этом проходе не выполняем (неизвестное имя ничего не добавляет в ИЛИ).

Все семь билтинов применимы и как записи type: builtin проверок, и как тела шагов деплоя / блоки экшенов check:.

Запускает shell-команду через жёстко зафиксированный sh -c (по соглашению с предикатом when: деплоя). Exit 0 = pass. Этот билтин использует POSIX-переносимый sh -c независимо от настроенного в проекте shell, обеспечивая идентичный запуск проверок во всех окружениях.

Команда выполняется с рабочим каталогом, установленным в корень проекта, поэтому относительные пути означают то же самое, что и в file_exists и в условии when:, независимо от каталога, из которого вызван dwe.

КлючТипОбязательноеПо умолчаниюОписание
cmdstringда—Тело shell-команды.
timeoutdurationнет10sМаксимальное время выполнения. 0 означает без ограничения (а не «истечь немедленно»).

Сообщение об ошибке при ненулевом exit: exit status N: <последняя строка stderr>.

См. deploy: cmd: shell vs type: shell для различия между этим билтином и типом выполнения шага type: shell.

Проверяет, что файл присутствует на диске.

КлючТипОбязательноеОписание
pathstringдаПуть относительно корня проекта.

Проверяет, что бинарь разрешается через exec.LookPath.

КлючТипОбязательноеОписание
namestringдаИмя исполняемого файла (без сегментов пути).

Проверяет, что один или несколько ключей существуют с непустыми значениями в файле в стиле .env. Парсинг следует соглашениям .env: пустые строки и full-line #-комментарии пропускаются; обрамляющие кавычки "..." / '...' снимаются; KEY=, KEY="" и KEY='' все считаются пустыми.

КлючТипОбязательноеОписание
filestringдаПуть к файлу в стиле .env, относительно корня проекта.
keyslist of stringsдаКлючи, которые должны присутствовать И быть непустыми.

Сообщение об ошибке: missing or empty keys: A, B, C.

Проверяет, что один или несколько точечных путей резолвятся в непустые значения в смерженной конфигурации DWE — слоях workspace.yml / defaults.yml / local.yml после слияния. Это конфиг-ориентированный аналог env_keys_present: вместо чтения .env с диска он читает смерженный конфиг в памяти, поэтому сразу видит оверлеи local.yml и не зависит от того, материализован ли уже отрендеренный .env.

Адресация — тот же точечный путь, который setup-визард использует в поле writes:, так что проверяемый путь — ровно тот, в который визард записал — например vars.db.api_key или vars.app.log_level. Сочетайте с stages: [post-setup], чтобы проверка запускалась после того, как визард заполнит local.yml.

КлючТипОбязательноеОписание
keyslist of stringsдаТочечные пути в смерженный конфиг; каждый должен резолвиться в непустое значение.

Путь считается «отсутствующим», когда он не резолвится, когда резолвится в null или когда рендерится в пустую строку. Нестроковые скаляры — числа, булевы — считаются присутствующими. Сообщение об ошибке: missing or empty keys: vars.db.api_key, vars.app.log_level.

Какие пути достижимы. Проверяйте те же пути, которые может писать визард — см. scope writes: в setup. Кастомные значения живут в песочнице vars: (vars.db.*, vars.app.*, …) — корень смерженного конфига строгий, поэтому свободные ключи должны быть вложены под vars:, чтобы пережить слияние и резолвиться здесь. Под services.<name> в local.yml допускаются только enabled, ports.<name> и hosts.<name> — и визард, и загрузчик конфига отвергают всё остальное, поэтому посервисный секрет не может жить по пути services.<name>.env.* в local.yml. Держите посервисные секреты в отрендеренном .env сервиса и проверяйте их через env_keys_present; config_keys_present — для top-level значений, которые пишет визард.

Пытается сделать TCP-dial до host:port.

КлючТипОбязательноеПо умолчаниюОписание
hoststringда—Имя хоста или IP.
portintда—Порт в диапазоне 1–65535.
timeoutdurationнет3sТаймаут dial’а.

Выполняет HTTP GET и утверждает статус ответа (и, когда задано, подстроку тела), повторяя при сбое.

КлючТипОбязательноеПо умолчаниюОписание
urlstringда—Абсолютный http/https URL с хостом.
statusintнет200Ожидаемый код статуса.
containsstringнет—Подстрока, которая должна присутствовать в теле ответа.
retriesintнет0Дополнительные попытки после первой (всего попыток = retries + 1).
intervaldurationнет1sОжидание между попытками.
timeoutdurationнет5sТаймаут на попытку.

Сообщение об ошибке при сбое: http_check <url>: expected status 200, got 503 (с добавлением (after N attempts), когда настроены ретраи). Полное описание поведения см. в справочнике билтинов.

Запись проверки с type: command диспатчится в декларативную пользовательскую команду из workspace/commands/. Блок with: пробрасывается как payload params: пользовательской команды — ровно как dwe commands <id> --set k=v.

Ограничения, проверяемые на загрузке:

  • type: целевой команды ДОЛЖЕН быть shell или script. Цели workflow, service_exec, service_run, dwe и builtin-as-command отвергаются с сообщением: checks may only invoke user commands of type shell or script (got: <type>).
  • Неизвестный ID команды отвергается с сообщением: unknown command: <id>.

Выполнение зафиксировано:

  • SkipConfirm = true — промпты подтверждения обходятся.
  • NonInteractive = true — UI-пути с промптами короткозамыкаются.
  • SkipNotify = true — desktop-уведомления подавляются.
  • stdout отбрасывается; stderr захватывается, и его хвост включается в сообщение диагностики, если проверка падает.

Проверки должны быть идемпотентной инспекцией

Заголовок раздела «Проверки должны быть идемпотентной инспекцией»

Проверка отвечает на вопрос «готов ли мир?», а не «подготовь мир». По соглашению, каждая проверка ДОЛЖНА быть идемпотентной, без побочных эффектов и быстрой.

CLI этого НЕ обеспечивает. Read-only песочницы для подпроцессов нет; проверка type: command, чьё тело — rm -rf /tmp/work, выполнит ровно это в preflight.

Что CLI ОБЕСПЕЧИВАЕТ для проверок type: command:

  • Неинтерактивное выполнение (без промптов, без подтверждений).
  • Уведомления подавлены.
  • stdout отброшен, stderr захвачен.

Компромисс: CLI держит мост минимальным, чтобы пользовательские shell/script-команды можно было переиспользовать и для шагов деплоя, и для проверок готовности, не вводя новый ограниченный режим выполнения. Изменяющая проверка — это острый край для автора: явно документируйте это в description:, если ваша проверка обязана что-то менять, и предпочитайте чистую инспекцию (shell: docker pull ... --quiet, shell: test -f path) везде, где возможно.

1. Логин в реестр контейнеров (встроенный shell):

checks:
- id: ghcr-login
description: Authenticated against ghcr.io
stages: [deploy]
severity: error
hint: |
Run `docker login ghcr.io` with a GitHub PAT.
type: builtin
cmd: shell
with:
cmd: docker pull ghcr.io/owner/private-image:latest --quiet
timeout: 30s

2. Локальный дамп БД присутствует (file_exists):

- id: db-dump-present
description: Seed dump exists for first-run import
stages: [deploy]
severity: warning
hint: Download from s3://team-dumps/latest.sql and place at .dwe/seed.sql
type: builtin
cmd: file_exists
with:
path: .dwe/seed.sql

3. Необходимые секреты сконфигурированы (env_keys_present):

- id: app-secrets
description: Required app secrets configured in .env
stages: [run, deploy]
severity: error
hint: |
Copy .env.example to .env and fill in:
DATABASE_URL, REDIS_URL, JWT_SECRET
type: builtin
cmd: env_keys_present
with:
file: .env
keys: [DATABASE_URL, REDIS_URL, JWT_SECRET]

4. Проверка с привязкой к сервису (services gate):

- id: api-jwt-secret
description: JWT_SECRET configured for API
stages: [run, deploy]
services: [api] # запускается только когда api включён
severity: error
hint: Set JWT_SECRET in services/api/.env
type: builtin
cmd: env_keys_present
with:
file: services/api/.env
keys: [JWT_SECRET]

5. Значение от визарда обязательно перед деплоем (post-setup + config_keys_present):

- id: db-api-key-set
description: vars.db.api_key must be set before deploy
stages: [post-setup] # только финальный preflight — после setup-визарда
severity: error
hint: |
Run `dwe deploy` and complete the wizard, or set
vars.db.api_key in workspace/local.yml.
type: builtin
cmd: config_keys_present
with:
keys: [vars.db.api_key]

Setup-визард пишет vars.db.api_key в local.yml (путь под песочницей vars: — services.<name>.env.* НЕ является легальной целью визарда/local.yml, см. заметку о достижимости выше); эта проверка утверждает, что тот же точечный путь задан. Поскольку она post-setup, на раннем pre-wizard gate она пропускается (чтобы визард был достижим) и запускается на финальном preflight — ловя незаданное значение до старта деплоя, включая dwe deploy run, где визарда нет.

6. Корпоративный VPN доступен (tcp_reachable):

- id: corporate-vpn
description: Internal git mirror is reachable (VPN up?)
stages: [deploy, run]
severity: error
hint: Connect to the corporate VPN and retry.
type: builtin
cmd: tcp_reachable
with:
host: git.internal.example.com
port: 22
timeout: 2s

7. Скрипт проверки зависимостей проекта (type: command):

- id: project-deps
description: Required CLIs installed (./scripts/check-deps.sh)
stages: [run]
type: command
cmd: deps.check

Где workspace/commands/deps.yml объявляет:

group: deps
commands:
check:
type: shell
description: Verify required CLIs
cmd: |
set -e
command -v node
command -v pnpm
command -v psql

8. Исполняемый файл в PATH (executable_in_path):

- id: jq-installed
description: jq is available for compose introspection helpers
stages: [deploy]
severity: warning
type: builtin
cmd: executable_in_path
with:
name: jq
  • dwe validate — запускает config.*, templates.*, commands.*, bridge.*, secrets.*, env.* и все checks.*. Опциональный позиционный scope сужает запуск (например, dwe validate env, dwe validate checks ghcr-login, dwe validate bridge, dwe validate secrets). Строка итога называет активный scope — (scope: all), (scope: config), (scope: config/services), — а --output json несёт то же значение в summary.scope, так что суженный запуск отличим от полного не только числом диагностик.
  • dwe validate secrets — только три валидатора secrets.*: наличие и форма recipient, повреждённые payload’ы маркеров, то, расшифровывается ли здесь каждое зашифрованное значение и каждый источник пака .age, и то, не затеняет ли маркер плейнтекст из вышележащего слоя. Работает даже когда конфиг не загрузился (некорректный secrets.recipient — ровно этот случай), потому что валидатор recipient сам грузит файлы слоёв. Ту же картину в виде отчёта, который ничего не блокирует, даёт dwe secrets status.
  • dwe validate bridge — статические проверки только per-service блоков bridge:: enum on_unreachable (fail / warn), абсолютность shim_path и маппинг dir / dir_internal сервиса с включённым мостом, поверх которого работает shim. Только для validate — домен bridge не участвует в preflight.
  • dwe validate --stage <name> — локальный флаг команды validate. Фильтрует checks.* по стадии. env.* и другие домены не затрагиваются (у них нет стадий).
  • dwe validate --strict — трактовать предупреждения как ошибки (exit 1).
  • dwe validate --quiet — скрыть строки ok / info.
  • dwe validate --level <levels> — показать только указанные уровни серьёзности (через запятую: ok, info, warning, error; например --level error,warning). Только для отображения — не влияет ни на итоговые счётчики, ни на код выхода. Применяется и к таблице, и к --output json.
  • Подсказка о фильтрации. После длинного запуска в человекочитаемом режиме — больше 20 отрисованных строк — dwe validate печатает одну info-строку в stderr, называя --level и --quiet. Она подавляется, если любой из этих флагов уже задан, если все показанные строки — ошибки (ни один флаг ничего бы не убрал), и в режиме --output json, где stdout остаётся разбираемой поверхностью, а потребитель фильтрует массив сам.
  • --skip-preflight — локальный флаг для deploy run, run, stop, restart и reset run. Если задан, preflight печатает preflight skipped (--skip-preflight) в stderr и НЕ запускает валидаторов. Флаг — это полноценный байпас: проверки type: command вызывают произвольные пользовательские скрипты, поэтому CLI не запускает их под флагом, который пользователь назвал «skip».

Диагностики используют ту же модель рендеринга и severity, что и остальной dwe validate:

  • Severity: из entry.severity (по умолчанию error).
  • Domain: checks (или env для жёстко зафиксированных probe’ов).
  • Target: id записи.
  • File: workspace/validate.yml (записи) или пусто (env-probe’ы).
  • Line: номер строки (1-based) первого ключа записи (записи).
  • Message: строка ошибки билтина / команды.
  • Hint: из entry.hint.

Preflight пишет ту же таблицу диагностики в stderr перед падением с exit-кодом 1. Используйте \n в подсказках, чтобы разбить длинный текст устранения на строки — таблица Lipgloss соблюдает переводы строк.

Таблица диагностики подстраивается под терминал, в который она пишется: по мере сужения терминала колонки ужимаются и переносятся, а ниже границы, где колонки ещё помещаются, таблица заменяется блоками записей с подписями — по блоку на диагностику, строки вида label value вместо ячеек. Ничего никогда не обрезается: длинный URL из подсказки остаётся целым и копируемым в обеих раскладках, поэтому ссылка шире терминала — единственное, что всё ещё может выйти за его границы.

Когда вывод уходит в пайп или перенаправляется, ширина терминала неизвестна, поэтому адаптивная раскладка отключается и печатается неизменная полноширинная таблица — dwe validate > report.txt даёт одни и те же байты независимо от терминала, в котором команда запущена. Для машинного вывода используйте --output json.

Ширина берётся от того потока, в который команда фактически пишет: dwe validate печатает свою таблицу в stdout, а preflight и меню деплоя — в stderr. Перенаправление одного не влияет на другое.

Домен linters.* запускает известные внешние линтеры (shellcheck, hadolint) и произвольные адаптеры type: generic как часть dwe validate. Линтеры не запускаются в preflight — preflight отвечает на вопрос «можем ли мы запуститься?», а не «чист ли код?».

linters:
shellcheck:
enabled: true
bin: shellcheck
paths: [workspace/scripts, scripts]
extensions: [.sh, .bash]
flags: [--severity=warning]
severity: warning
hadolint:
paths: ["."]
filenames: [Dockerfile]
extensions: [.dockerfile]
yamllint:
type: generic
bin: yamllint
paths: ["."]
extensions: [.yml, .yaml]
flags: [-s]

Ключ маппинга — это ID адаптера. Неизвестные поля отвергаются на загрузке (строгое декодирование).

ПолеТипОбязательноеОписание
typestringнетbuiltin (по умолчанию) или generic.
enabledboolнетОпущено → автоопределение (true, если bin есть в PATH). false → молчаливый пропуск.
binstringнетПо умолчанию — дефолт адаптера (например, shellcheck). Должно быть голым именем команды — без сепараторов пути. Абсолютные или относительные пути отвергаются на загрузке.
pathslist of stringsнетПо умолчанию — дефолт адаптера. Каждая запись должна быть относительной, непустой и не содержать ... "." разрешён (равенство корню, используется hadolint).
extensionslist of stringsнетПо умолчанию — дефолт адаптера. Каждая запись должна начинаться с . (например, .sh, не sh).
filenameslist of stringsнетЛитеральные basename’ы, матчащиеся рядом с расширениями (например, Dockerfile). Сепараторы пути не разрешены.
flagslist of stringsнетДописываются после встроенных флагов адаптера. Встроенные адаптеры резервируют флаги output-формата (--format, -f) — передача их в любой argv-форме (--format=gcc, -f tty, -fgcc) отвергается на загрузке.
severitystringнетОдно из error, warning, info. Ограничивает находки адаптера сверху (например, severity: warning понижает находки адаптера уровня error до warning). ok не разрешено — используйте enabled: false для отключения. Операционные диагностики (timeout, truncation, parse failure, missing-path) никогда не ограничиваются, так что пользователь не может случайно заглушить сигналы рантайм-падений.
IDBin по умолчаниюPaths по умолчаниюExtensions по умолчаниюFilenames по умолчаниюЗарезервированные флаги
shellcheckshellcheckworkspace/scripts, scripts.sh, .bash—--format, -f
hadolinthadolint..dockerfileDockerfile--format, -f

Generic-адаптер запускает bin <flags> <files...> и конвертирует ненулевой exit в одну диагностику severity error с объединённым stdout+stderr в качестве сообщения (обрезано до ~2 KB, чтобы таблица оставалась читаемой). У него нет зарезервированных флагов — пользователь сам управляет всем набором флагов — и нет построчного парсинга. Используйте его для линтеров, чей формат вывода мы не парсим нативно.

  1. Для каждого известного встроенного адаптера, если в linters: нет записи → синтезировать запись с дефолтами (на адаптер, не all-or-nothing).
  2. Блок присутствует, enabled опущен → true.
  3. enabled: false → молчаливый пропуск (без диагностики).
  4. Дефолтный bin: отсутствует в PATH → молчаливый пропуск («мы попробовали автоопределить; делать нечего»).
  5. Явный bin: задан, но отсутствует в PATH → одна Warning-диагностика (проблема конфига, не кода).
  6. Раскрытие путей не дало файлов → молчаливый пропуск.

Можно переопределить путь к бинарю для любого линтера через свой пользовательский конфигурационный файл (~/.config/dwe/config). Полезно, когда у вас кастомные установки, замены (например, podman вместо docker) или бинари вне стандартного PATH.

Добавьте строку в свой user config:

binary_shellcheck=/custom/path/to/shellcheck
binary_hadolint=/opt/hadolint

Формат — binary_<linter-id>=<path>. Пути могут быть абсолютными или относительными к текущей директории. Если путь не существует или не исполняем, dwe validate выводит error-диагностику в домене linters.

Заметка: Эти переопределения учитываются только во время dwe validate. Lifecycle-команды (deploy, run, stop и т. д.) не используют бинари линтеров, так что сломанные переопределения не влияют на обычную работу.

Запустить все линтеры или сузить до одного через подкоманду linters:

dwe validate # all domains (including linters)
dwe validate linters # all linters
dwe validate linters shellcheck # only shellcheck

Неизвестные ID линтеров дают пустой результат (не жёсткую ошибку — зеркалит поведение checks).

  • Timeout: 5 минут на линтер (DefaultLinterTimeout). Превышение → Error-диагностика; частичный вывод не парсится.
  • Лимит вывода: 50 MB объединённого stdout+stderr на линтер (MaxLinterOutputBytes). Излишек отбрасывается, и выводится Warning-диагностика; парсер всё равно запускается на захваченном префиксе.
  • Конкурентность: линтеры запускаются параллельно, ограничено runtime.NumCPU() (MaxLinterConcurrency). Падение одного линтера (panic, timeout, ошибка парсера) никогда не отменяет соседей.
  • Записи paths: рекурсивно обходятся внутри корня проекта.
  • Явные пути к файлам (записи, разрешающиеся в обычный файл) обходят фильтры extensions/filenames.
  • Файл матчится, если его расширение в extensions: ИЛИ его basename в filenames:.
  • Симлинки пропускаются (защита от выходов за пределы корня проекта).
  • .git/ всегда пропускается. Сужение специфичного для адаптера шума (например, node_modules, vendor) оставлено пользователю через paths:.
  • Отсутствующие дефолтные пути (например, workspace/scripts shellcheck’а в проекте, где их нет) молча отбрасываются. Отсутствующие пользовательские пути (записи, явно написанные пользователем) дают Warning.

bin: ограничен голым именем команды, разрешаемым через PATH в рантайме; абсолютные и относительные пути запрещены на загрузке. Обоснование: validate.yml едет с репозиторием; вредоносный конфиг с bin: ./scripts/evil.sh не должен молча выполнять произвольный код на dwe validate. Пользователи, которым действительно нужен кастомный путь бинаря, устанавливают его в PATH (или оборачивают).

  • dwe validate — полный прогон валидации (все домены).
  • dwe validate env — только env-probe’ы.
  • dwe validate checks [id] — декларативные проверки (опциональный id сужает до одной).
  • dwe validate linters [id] — внешние линтеры (опциональный id сужает до одного).
  • dwe deploy run / run / stop / restart — автоматически вызывают preflight (см. --skip-preflight). Линтеры в preflight не запускаются.