# Расширения сборки

Elide включает компилятор, резолвер Maven, сборщик контейнеров и средство запуска тестов. В нём нет `protoc`, `shellcheck` или того, что понадобится вашему проекту дальше. **Расширение сборки** — способ научить сборку работать с таким инструментом: это модуль на JavaScript или TypeScript, который Elide вычисляет во время конфигурирования сборки.

Расширение объявляет задачи. Каждая становится настоящей задачей графа: её можно указать как цель сборки, сделать от неё зависимой другую задачу, поместить в группу и увидеть в `elide build --inspect`.

## Объявление расширения

Укажите его в манифесте относительно корня проекта:

```pkl
amends "elide:project.pkl"

name = "demo"

extensions {
  "tools/protoc.ts"
}
```

Расширения объявляются, а не обнаруживаются, поэтому запуск расширения — всегда то, о чём попросил ваш проект. Несуществующий путь приводит к ошибке сборки, а не к тому, что молча ничего не регистрируется.

Путь разрешается относительно корня проекта и должен оставаться внутри дерева исходников — корня проекта или, если проект входит в рабочее пространство, корня рабочего пространства, чтобы участники могли использовать общий файл выше своих корней (`../tools/protoc.ts`). Абсолютный путь вне дерева, путь, выходящий из него, и символическая ссылка, указывающая наружу, отклоняются, и о каждом сообщается так же, как о любом другом некорректном объявлении.

Объявление указывает на одно из двух, и Elide определяет, на что именно, только по расширению файла: ES-модуль — `.ts`, `.mts`, `.js`, `.mjs`, `.jsx`, `.tsx` — или архив пакета `.elx` (см. [Упаковка расширения](#packaging-an-extension)). `.cjs` не принимается: контракт — экспорт по умолчанию, а скрипт CommonJS не может его объявить; файл `.js` всегда читается как модуль, что бы ни говорил ближайший `package.json`. Всё остальное приводит к ошибке сборки, а не к попытке угадать.

Запись вообще не обязана быть путём: она может указывать на пакет, загружаемый по URL, из npm или из Maven. См. [Загрузка расширения](#fetching-an-extension).

## Написание расширения

```ts
export default ({ task }: ElideBuild) => {
  task(
    "protoc",
    {
      description: "Generate protobuf sources",
      group: "compile",
      inputs: ["proto/**/*.proto"],
      outputs: ["src/main/generated"],
    },
    (ctx) => {
      const result = ctx.exec("protoc", [
        `--kotlin_out=${ctx.layout.projectDir}/src/main/generated`,
        "proto/api.proto",
      ]);
      if (!result.ok) ctx.fail(result.stderr);
    },
  );
};
```

Затем:

```bash
elide build --inspect   # protoc  Generate protobuf sources
elide build protoc      # run just that task
elide build compile     # run it with everything else in its group
```

Типы поставляются в [`@elide-dev/types`](https://www.npmjs.com/package/@elide-dev/types) как `ElideBuild`, `ElideBuildTaskContext` и `ElideBuildTaskOptions`.

Экспорт по умолчанию вашего модуля выполняется во время конфигурирования сборки и может быть `async`. Действие каждой задачи выполняется вместе с задачей и тоже может быть `async`; сборка дожидается в обоих случаях.

## Упаковка расширения

Расширение, которое выросло из одного файла, поставляется архивом: zip с манифестом `extension.json` в корне и именем с расширением `.elx`. Имя — не украшение: именно расширение файла в объявлении сообщает Elide, что перед ним пакет, и `.elx` — единственное такое расширение, поэтому `tools/protoc.elx` загружается, а те же байты под именем `tools/protoc.zip` — нет.

```
protoc-tasks/
├── extension.json
├── tasks.ts
└── lib/
    └── flags.ts
```

```json
{
  "name": "protoc-tasks",
  "version": "1.0.0",
  "description": "Generates sources with protoc",
  "license": "Apache-2.0",
  "repository": "https://github.com/example/protoc-tasks",
  "homepage": "https://example.dev/protoc-tasks",
  "authors": ["Example Inc."],
  "type": "source",
  "entrypoint": "tasks.ts"
}
```

Манифест архива требует `name` и `type`. `type` — это `source` (единственный существующий вид), и пакет `source` также должен указать `entrypoint` — путь относительно корня архива; указывающий за пределы архива отклоняется. Всё остальное — метаданные, которые Elide сообщает вам обратно, а неизвестные ключи игнорируются, так что пакет может нести собственные ключи.

### Сборка пакета

Объявите пакет как артефакт, и `elide build` создаст его:

```pkl
amends "elide:project.pkl"

import "elide:Extensions.pkl" as Extensions

name = "protoc-tasks"
version = "1.0.0"
description = "Generates sources with protoc"

artifacts {
  ["tasks"] = new Extensions.ExtensionArchive {
    entrypoint = "tasks.ts"
    srcs { "lib/**/*.ts" }
    license = "Apache-2.0"
    repository = "https://github.com/example/protoc-tasks"
    authors { "Example Inc." }
  }
}
```

Так регистрируется задача `package-extension-tasks`, которую запускает обычный `elide build` и которая записывает `.dev/artifacts/tasks.elx`. Показанный выше `extension.json` записывается за вас: `name`, `version` и `description` по умолчанию берутся из проекта, а `type` всегда равен `source`.

`entrypoint` — модуль, экспорт по умолчанию которого объявляет ваши задачи. `srcs` — модули, которые он импортирует, в виде путей или glob-шаблонов относительно корня проекта; сама точка входа упаковывается всегда. Файлы сохраняют пути, которые у них есть в проекте, поэтому относительные импорты внутри архива разрешаются точно так же, как в вашей рабочей копии. Каждый упакованный файл после разрешения ссылок должен находиться внутри проекта: символическая ссылка за пределы проекта приводит к ошибке сборки, а не к поставке того, на что она указывает.

Разумеется, zip можно собрать и самостоятельно — из каталога с манифестом, чтобы `extension.json` оказался в корне:

```bash
cd protoc-tasks && zip -r ../tools/protoc.elx .
```

### Пакет исходников

`"type": "source"` означает, что архив содержит ваши исходники, а расширение файла точки входа определяет, как его загружать, — точно так же, как для файла, объявленного напрямую. Точка входа `.ts`, `.tsx`, `.mts`, `.js`, `.jsx` или `.mjs` вычисляется как гостевой модуль, и поскольку Elide распаковывает архив до вычисления, относительные импорты внутри пакета (`import { flags } from "./lib/flags.ts"`) разрешаются.

По пути ничего не бандлится и не компилируется: пакет поставляет написанные вами исходники, и вы пишете одно и то же расширение, распространяется ли оно одним файлом или архивом, — тот же экспорт по умолчанию, тот же `task(...)`, тот же `ctx`.

### Объявление пакета

Как локальное расширение — путь, с настройками, если они нужны, — или как то, что нужно загрузить:

```pkl
extensions {
  "tools/shellcheck.elx"
  new {
    from = "tools/protoc.elx"
    config { greeting = "hei" }
  }
}
```

Elide распаковывает архив в каталог сборки, в `.dev/extensions/packages/<archive>-<hash>/contents/`, и распаковывает заново при каждом изменении архива. Запись, которая была бы записана за пределы этого каталога, отклоняется, а не распаковывается.

### Что показывает `--inspect`

`elide build --inspect` выводит раздел `Extensions:` со всем, что объявил ваш манифест. Для каждого расширения показываются написанное вами объявление, имя и версия пакета, его описание, вид расширения — `JavaScript/TypeScript module` или `packaged extension`, — его лицензия, репозиторий и имена объявленных им задач.

```
Extensions:
  tools/protoc.elx   protoc-tasks 1.0.0
    Generates sources with protoc
    packaged extension · Apache-2.0 · https://github.com/example/protoc-tasks · Example Inc.
    tasks: protoc-generate, protoc-clean
```

Распространение архива — ваша забота: прикрепите его к релизу, раздавайте по URL, опубликуйте как пакет npm теми средствами, которыми уже пользуетесь. Как бы он ни распространялся, проект использует его одинаково — см. [Загрузка расширения](#fetching-an-extension).

## Загрузка расширения

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

```pkl
extensions {
  "url:https://downloads.example.com/protoc-tasks-1.4.0.elx"
  "npm:@acme/protoc-tasks@1.4.0"
  "maven:com.acme:protoc-tasks:elx:1.4.0"
  new {
    from = "npm:@acme/codegen@2.0.1"
    config { proto = "src/proto" }
  }
}
```

Зарезервированы только `url:`, `npm:` и `maven:`. Всё остальное — путь, поэтому `C:\tools\ext.ts` по-прежнему означает то, что написано, а схема, которую Elide добавит позже, не сможет изменить чтение манифеста, который уже собирается.

После загрузки ничего не меняется. Загруженный `.elx` загружается как пакет, а загруженный `.ts` — как гостевой модуль: решает то, во что он разрешился, а не то, как он попал к вам. `url:` должен быть абсолютным URL `https`; незашифрованный `http` отклоняется, кроме обращений к loopback-хосту, — это позволяет тестировать на локальном сервере. Перенаправления выполняются — до пяти переходов, — и к каждому переходу применяется то же правило, поэтому перенаправление не может увести loopback-загрузку на другую машину без шифрования.

### Версии закреплены

Удалённая версия указывает ровно на один выпуск. Схема манифеста отклоняет `npm:tasks` и `maven:com.acme:tasks`, потому что они не указывают ни одного, а `npm:tasks@^1.2`, `npm:tasks@latest` и `maven:com.acme:tasks:1.2.3-SNAPSHOT` — потому что указывают больше одного.

Так требует кеш. Загруженное расширение хранится под тем, что вы объявили, поэтому плавающая версия либо стоила бы запроса к реестру при каждой сборке, что сделало бы `--offline` невозможным, либо молча застыла бы на том, что увидела первая сборка.

### Где хранится кеш

Загруженные артефакты попадают в `.dev/dependencies/extensions/remote/`, рядом с разрешённым репозиторием Maven, и так же делятся на всё рабочее пространство. Поскольку объявление закреплено, кеш никогда не устаревает: в сеть обращается только первая сборка, а дальше работает `elide build --offline`. Объявление, которое ещё не загружено и не может быть загружено, сообщает об этом:

```
Build extension 'npm:@acme/protoc-tasks@1.4.0' has not been downloaded, and --offline forbids fetching it
Run the build once without --offline to populate the cache.
```

Проверяется любой дайджест, который публикует источник: `dist.integrity` или `dist.shasum` у npm и сопутствующий файл `.sha1` у Maven. Репозиторий, отдающий артефакт без дайджеста, допустим; репозиторий, отдающий и то и другое, но противоречащий сам себе, приводит к ошибке сборки.

### Закрепление байтов

Закреплённая версия говорит, какой выпуск запускается; закреплённый `integrity` — какие именно байты. Записывайте его в формате Subresource Integrity — `sha256-<base64>` или `sha512-<base64>` — рядом с источником, к которому он относится:

```pkl
extensions {
  new {
    from = "url:https://downloads.example.com/protoc-tasks-1.4.0.elx"
    integrity = "sha512-Ln7jRoUMYMK7TBZ0bNyPmrHDCgAsQ3bIt5nZeUuzpuGqBbw1zYWTPdA2tRbClC2CoRP3UExpHnCw+5kZLgP0lQ=="
  }
}
```

`url:` доверяют только на основании TLS и хоста, поэтому воспроизводимым его делает закрепление. Оно проверяется перед помещением загрузки в кеш и снова при каждом попадании в кеш, а пакет npm запускается из дерева, извлечённого из тех же проверенных байтов, так что ни сервер, ни общий кеш рабочего пространства не могут незаметно для вас изменить то, что запускается. Кешированный файл, который больше не совпадает с закреплением, загружается заново и заменяется; при `--offline` сборка вместо этого завершается ошибкой с указанием кешированного файла. Закрепление дополняет то, что публикует реестр, а не заменяет его: дайджест npm или Maven по-прежнему проверяется.

Закрепление принимает только загружаемый источник. Закрепить путь внутри проекта нельзя — его байты и так те, что лежат в вашем дереве.

Загрузка ограничена по размеру и времени: ответ больше 64 MiB приводит к ошибке, как и хост, который ответил и затем завис, — вместо того чтобы подвесить конфигурирование.

### Пакеты npm

Tarball кешируется один раз на рабочее пространство, извлекается в каталог сборки проекта, в `.dev/extensions/npm/<name>/<version>/contents/`, и загружается из своей точки входа: той, что указана в `extension.json`, если пакет его содержит, иначе `main` из `package.json`, иначе `index.js`. Пакет с `extension.json` — пакет в полном смысле и читается точно так же, как `.elx`: некорректный или указывающий `type`, неизвестный этой версии Elide, приводит к ошибке сборки, а не к откату на `main`.

Разрешение проходит три шага по порядку: `dependencies.npm.registry`, если манифест его задаёт, затем `$ELIDE_PKGST_NPM_ENDPOINT` — та же переменная, которую читает собственный резолвер npm в Elide, — а если нет ни того ни другого, прокси Elide `npm.pkg.st` или публичный реестр `registry.npmjs.org`, если передан `--use-pkgst=false`.

```pkl
dependencies {
  npm {
    registry = "https://npm.internal.example.com"
  }
}
```

### Артефакты Maven

Координата загружается из уже объявленных вами репозиториев — `dependencies.maven.repositories`, а также прокси Elide и Maven Central, если `enableDefaultRepositories` не отключён, — с учётными данными этих репозиториев. Учётные данные отправляются только на схему, хост и порт самого репозитория; перенаправление куда-либо ещё выполняется без них. Обычный `[:packaging[:classifier]]` стоит между артефактом и версией, а координата без упаковки получает `elx`: `maven:com.acme:tasks:1.4.0` загружает `tasks-1.4.0.elx`, так же как `maven:com.acme:tasks:elx:1.4.0`. Упаковка, которую Elide не может загрузить (`maven:com.acme:tasks:zip:1.4.0`), отклоняется, как и любое незагружаемое объявление.

Репозитории перебираются по порядку. Репозиторий, ответивший ошибкой, слишком большим или зависшим ответом либо отклонённым перенаправлением, пропускается в пользу следующего; опубликованный `.sha1`, не совпадающий с артефактом, приводит к ошибке сборки.

Загружается один артефакт, и граф зависимостей не разрешается — точно так же, как для архива, объявленного путём. Расширение, которому нужны библиотеки, поставляет их внутри своего пакета.

## Настройка расширения

Расширение, принимающее настройки, объявляется блоком, а не просто путём, и несёт их с собой. Elide их не интерпретирует:

```pkl
extensions {
  "tools/shellcheck.ts"
  new {
    from = "tools/protoc.ts"
    config {
      version = "25.1"
      plugins = new Listing { "grpc" }
    }
  }
}
```

Этот блок попадает в это расширение — и только в него — как `config`, пока оно объявляет задачи и пока они выполняются:

```ts
export default ({ task, config }: ElideBuild) => {
  task("protoc", { description: `protoc ${config.version}` }, (ctx) => {
    const { version, plugins } = ctx.config;
  });
};
```

Расширение, объявленное просто путём, читает пустой `config`, так что для значения по умолчанию достаточно проверить, что настройка равна `undefined`.

Расширение, которому нужны типизированные и документированные настройки, поставляет собственный модуль Pkl, который ваш проект копирует к себе, импортирует и заполняет. Манифест импортирует локальные файлы, стандартную библиотеку Pkl и собственную схему Elide — но никогда `package://` или другой сетевой URI, — поэтому модуль находится в вашем проекте:

```pkl
import "tools/Protoc.pkl"

extensions {
  new {
    from = "tools/protoc.ts"
    config = new Protoc.Settings { version = "25.1" }
  }
}
```

Записи различаются по пути, как бы вы их ни записали, поэтому одно и то же расширение нельзя объявить — или настроить — дважды.

Список внутри блока записывайте как `Listing` (или `List("grpc")`). Встроенные элементы — `plugins { "grpc" }` — попали бы в расширение с ключами-позициями, а не списком, поэтому манифест их отклоняет и подсказывает работающую запись.

Настройки — это данные. Значение может быть строкой, логическим значением, числом, `Duration`, `DataSize` (попадает в расширение как число байтов) или `Regex`, а также блоком, `Listing`, `List`, `Set`, `Map` или `Pair` из них. `Bytes`, класс, псевдоним типа или функция — ошибка манифеста с указанием места, например `config.flags.blob`.

## Параметры задач

| Параметр | Значение |
| --- | --- |
| `description` | Показывается в `elide build --inspect`. |
| `dependsOn` | Имена задач, которые должны завершиться раньше; одно имя можно указать без массива. |
| `before` | Задачи, которые эта должна предварять: имя задачи или `deps`/`compile`/`test` для всех задач этой группы. |
| `group` | `deps`, `compile` или `test`, чтобы войти во встроенную группу; любое другое имя создаёт собственную. |
| `default` | Запускает ли эту задачу `elide build` без аргументов. По умолчанию `true`. |
| `inputs` | Пути или glob-шаблоны относительно проекта, содержимое которых решает, нужно ли перезапускать задачу. |
| `outputs` | Пути или glob-шаблоны относительно проекта, в которые пишет задача. |
| `cache` | Может ли кеш сборки архивировать выходные данные задачи. По умолчанию `true`. |
| `options` | Флаги командной строки, которые принимает задача (см. ниже). |

`inputs` и `outputs` — единый контракт: объявите оба, и Elide пропустит задачу, если ничего не изменилось (см. [Задачи сборки](/docs/toolchain/build-tasks)); объявите один или ни одного — и задача будет выполняться при каждой сборке. Ваше расширение тоже считается входными данными — сам файл и каждый модуль, который он импортирует по относительному пути, — поэтому правка точки входа или одного из её вспомогательных модулей перезапускает объявленные задачи.

Задача, объявившая оба параметра, к тому же кешируется так же, как задачи самого Elide: её выходные данные архивируются при успехе и восстанавливаются в любой последующей сборке с тем же отпечатком входных данных, в том числе в свежей рабочей копии. Задайте `cache: false`, чтобы сохранить проверку актуальности и отказаться от архива, — это правильный выбор, когда выходные данные зависят от чего-то, что не указано в `inputs`.

Отпечаток объявления вычисляется из режима и содержимого каждого указанного в нём файла, а ваше действие читает те же файлы и те же отпечатки через `ctx.inputs` и `ctx.outputs` — см. [Файлы, указанные в объявлении](#the-files-a-declaration-names).

## Флаги, которые принимает задача

Задача, читающая флаг, должна его объявить. Объявленные флаги выводят `elide build --inspect` и `elide build <task> --help`, и они проверяются до запуска чего-либо, поэтому опечатка приводит к сообщению, а не молча отбрасывается:

```ts
task(
  "publish",
  {
    options: [
      { name: "channel", description: "Channel to publish to", arity: "required" },
      { name: "tag", description: "Extra tag to push", arity: "required", repeatable: true },
      { name: "sign", description: "Sign the release", negatable: true, default: "true" },
    ],
  },
  (ctx) => {
    ctx.option("channel"); // first value, or the declared default
    ctx.values("tag"); // every value, in command-line order
    ctx.flag("sign"); // `--no-sign` reads false; absent reads the default
  },
);
```

| Поле | Значение |
| --- | --- |
| `name` | Флаг без дефисов в kebab-case: `"dry-run"` — это `--dry-run`. |
| `description` | Показывается в `--inspect` и `--help`. |
| `arity` | `"none"` — флаг без значения, `"required"` требует `--flag=value`, `"optional"` принимает оба варианта. По умолчанию `"none"`. |
| `repeatable` | Можно ли передавать флаг несколько раз. По умолчанию `false`. |
| `negatable` | Принимается ли `--no-<name>`, читаемый как `"false"`. По умолчанию `false`. |
| `default` | Значение, которое возвращается при чтении, если флаг не передан. |
| `keyed` | Определяет ли переданное значение флага то, что производит задача. По умолчанию `true`. |

`--inspect` и `--help` выводят объявление так, как его нужно вводить, поэтому три флага выше выглядят как `--channel=<value>`, `--tag=<value> (repeatable)` и `--sign (or --no-sign) [default: true]`.

Флаг, который никто не объявил, приводит к ошибке сборки с ближайшим объявленным именем, если такое есть; то же происходит со значением для флага без значения, с обязательным флагом без значения и с повтором флага, принимающего одно значение. Собственные флаги Elide — `--verbose`, `--project`, `--offline` и остальные — принимаются всегда.

`ctx.option`, `ctx.flag` и `ctx.values` читают через объявление — именно это вообще делает `--no-sign` и значение по умолчанию читаемыми: ни то ни другое командная строка сама по себе не сообщает. Имя, которое задача не объявила, по-прежнему читается из командной строки в разобранном виде.

Объявленный флаг входит в то, что решает, перезапускать ли задачу: разрешённые значения записываются вместе с `inputs`, поэтому сборка с `--channel=stable` никогда не считается актуальной — и не получает то, что закешировал `--channel=canary`, — относительно сборки, выполненной с другим значением. Задайте `keyed: false` для флага, который меняет подробность вывода задачи, а не то, что она записывает, — иначе каждая сборка, где он записан иначе, будет впустую промахиваться мимо кеша. Флаг, который действие читает без объявления, ничего не решает.

## Запуск перед другой задачей

`dependsOn` указывает, что должно выполниться раньше. `before` указывает, что должно подождать, — включая задачи, которые вы не объявляли; именно это нужно задаче генерации кода:

```ts
task(
  "generate-sources",
  { before: "compile", default: false, outputs: ["src/main/generated"] },
  (ctx) => { ... },
);
```

`compile`, `deps` и `test` обозначают все задачи этой группы — так и должно пониматься «перед компиляцией»: настоящие ключи заданы для каждого языка и набора исходников (`compile-kotlin-main`, `compile-java-test`). Любое другое имя — имя одной задачи, а имя, которое никто не зарегистрировал, приводит к ошибке сборки, а не к тому, что молча ничего не выполняется. В рабочем пространстве расширение, объявленное участником, выбирает только задачи этого участника, а объявленное корневым манифестом — группу во всех участниках.

Такое ребро также включает вашу задачу в любую сборку, которая выполняет задачу, идущую после неё: `elide build compile` в примере выше сначала выполняет `generate-sources`, хотя она отказалась от `default`.

## Инкрементальные сборки

Выполняющаяся задача узнаёт, что сборка сочла устаревшим, и может сохранять значения до следующего запуска:

```ts
task("codegen", { inputs: ["proto", "schema"], outputs: ["gen"] }, (ctx) => {
  for (const input of ctx.dirty.inputs) ctx.log(`changed: ${input}`);
  if (ctx.dirty.outputs.length) ctx.log("an output is missing or was edited");
  if (ctx.dirty.extension) ctx.log("this extension or one of its imports changed");

  ctx.log(`last built version ${ctx.state.get("version") ?? "(never)"}`);
  ctx.state.set("version", ctx.manifest.version);
});
```

`ctx.dirty` называет только изменившиеся объявления; при первом запуске перечисляется всё объявленное. `ctx.state` хранит строки, числа, логические значения, `null`, массивы и простые объекты по именам, а `get` возвращает `null` для ключа, под которым ничего не сохранено. У функции, символа и `undefined` нет сохраняемой формы, а значение, содержащее само себя, отклоняется, а не обходится. Неудачный запуск полностью отбрасывает состояние задачи, включая то, что записали предыдущие запуски.

То, что задача запоминает, — заметка, а не входные данные: это никогда не решает, перезапускать ли задачу, и никогда не меняет её ключ кеша.

## Файлы, указанные в объявлении

`ctx.inputs` и `ctx.outputs` — это сами объявления, разрешённые и с отпечатками: тот же обход и тот же дайджест, по которым сборка решала, актуальна ли ваша задача:

```ts
task("codegen", { inputs: ["proto"], outputs: ["gen"] }, (ctx) => {
  for (const file of ctx.inputs.files()) {
    ctx.log(`${file.path} ${file.size}b ${file.stamp}`);
  }

  const before = ctx.state.get("proto") ?? {};
  const after = Object.fromEntries(ctx.inputs.files().map((f) => [f.path, f.stamp]));
  for (const path of Object.keys(after)) {
    if (after[path] !== before[path]) ctx.exec("protoc", ["--kotlin_out=gen", path]);
  }
  ctx.state.set("proto", after);

  ctx.log(`produced ${ctx.outputs.stamp()}`);
});
```

`files()` перечисляет все обычные файлы, указанные в объявлениях, относительно проекта, с разделителем `/`, отсортированные и без дубликатов. Каждая запись содержит `path`, `absolute`, `size`, `modified` и `stamp` — SHA-256 по режиму и содержимому файла. `stamp()` — дайджест по всему набору.

Оба принимают собственные шаблоны, не обязательно объявленные: `ctx.inputs.files("proto/api/**/*.proto")` сужает список, а `ctx.inputs.stamp("proto")` — ровно тот отпечаток, который сборка записала для этого объявления. Оба разрешаются в момент вызова, поэтому чтение `ctx.outputs` после записи выходных данных описывает только что созданное.

Отпечатки зависят от содержимого: правка, не меняющая размер и время изменения файла, всё равно их меняет и перезапускает задачу. `ctx.dirty` называет объявление целиком; отпечаток каждого файла, сохранённый в `ctx.state`, как в примере, позволяет задаче понять, какие из её файлов изменились.

## Объявления, которые сборка должна вычислить

Часть того, что решает, актуальна ли задача, нельзя записать в объявлении: сгенерированное дерево, classpath, разрешённый другой задачей, версию инструмента, который вы собираетесь запустить. Дайте задаче хук `prepare` и объявите это там:

```ts
task(
  "package",
  {
    outputs: ["dist"],
    prepare: (ctx) => {
      ctx.inputs.add("gen/**/*.kt");
      ctx.inputs.value("classpath", ctx.state.of("resolve")?.get("classpath"));
      ctx.outputs.add(`dist/${ctx.manifest.version}.tgz`);
    },
  },
  (ctx) => {
    for (const changed of ctx.dirty.values) ctx.log(`${changed} changed`);
  },
);
```

То, что добавляет хук, — такое же объявление, как любое другое: шаблон получает отпечаток, называется в `ctx.dirty.inputs` и входит в ключ кеша задачи точно так же, как объявленный. `ctx.inputs.value(name, value)` объявляет входные данные, не являющиеся файлом, — всё, что сборка может вычислить, но не может проверить через stat, — и их изменение перезапускает задачу и меняет ключ её архива; `ctx.dirty.values` называет, какие именно изменились. Задача, объявление которой дополнено так, отслеживается, кешируется и пропускается как актуальная, как любая другая.

Хук выполняется в каждой сборке, где участвует задача, включая те, где ей в итоге нечего делать, поэтому ограничьте его выводом объявлений: у него нет `exec`, `tools` и `dirty` — это и есть ответ, который он помогает вычислить, — а `ctx.state` там доступен только для чтения. Возврат `false` приводит к ошибке задачи до её запуска.

Действие может добавлять **выходные данные**, которые становятся известны только при записи, тем же `ctx.outputs.add(...)`; при возврате они получают отпечатки и архивируются вместе с остальными. Добавлять входные данные оно не может — к этому моменту сборка уже решила, запускать ли задачу, поэтому поздний `ctx.inputs.add` — ошибка, а не объявление, которое молча ничего не делает.

## Передача значения другой задаче

Модуль расширения вычисляется один раз за сборку и общий для всех объявленных им задач, поэтому переменная модуля, присвоенная одной задачей, часто ещё доступна следующей — но это ничем не гарантировано, а новое вычисление начинается с нуля. Относитесь к области видимости модуля как к кешу, а не как к каналу. Поддерживаемый способ передать значение — то, что задача хранит в `ctx.state`:

```ts
task("stage", (ctx) => {
  ctx.state.set("staged", `${ctx.layout.buildDir}/staged`);
});

task("publish", { dependsOn: "stage" }, (ctx) => {
  const stage = ctx.state.of("stage");
  if (stage === null) return ctx.skip("nothing has been staged");
  ctx.exec("rsync", [String(stage.get("staged")), "example:/srv"]);
});
```

`ctx.state.of(name)` возвращает представление состояния этой задачи — `get`, `has` и `keys` — и `null`, если она ничего не опубликовала. Задача, которую сборка пропустила как актуальную или восстановила из кеша, отвечает тем, что сохранила при последнем запуске: актуальность — это утверждение о том, что она произвела, и опубликованное входит в это. `null` означает, что читать было нечего: имя не является задачей, объявленной расширением, задачи не было в этой сборке, она завершилась ошибкой или ещё не выполнялась; а представление без ключей — это задача, которая ничего не сохранила. Сделайте свою задачу зависимой от той, которую читаете; без ребра «ещё не выполнялась» — это состояние гонки.

Значение передаётся в своей исходной структуре, а не текстом, и к тому, что можно передать, применяются те же правила, что и к `ctx.state`. Чтение собственного имени возвращает то, что задача хранит в данный момент.

## Что получает задача

Объект, передаваемый действию, содержит то же, что читает сама сборка:

- `project` — имя, версия, описание, корневой каталог и рабочее пространство, к которому относится проект.
- `manifest` — ваш разрешённый `elide.pkl` в виде обычных данных JavaScript. Это ровно то, что записывает `elide project export --format=json`, поэтому `manifest.dependencies.maven.packages.map(...)` работает ожидаемым образом.
- `config` — настройки, которые манифест объявил для этого расширения (см. выше), пустые, если ничего не объявлено.
- `layout` — `projectDir`, `buildDir`, `artifacts`, `staging` и другие, в виде абсолютных путей.
- `option(name)` / `flag(name)` / `values(name)` — флаги этого вызова, прочитанные через ваше объявление (см. выше); `options` — сырой результат разбора для всего, что вы не объявили.
- `task` — ваше собственное объявление в том виде, в каком его записала сборка, включая объявленные параметры.
- `dirty` / `state` — что изменилось с последнего запуска задачи, что она сохранила и что записали другие задачи этой сборки (см. выше).

## Сообщение о результате

| В действии | Что записывает сборка |
| --- | --- |
| return или возврат `true` | успех |
| `ctx.fail(reason)` | ошибка с этой причиной |
| возврат `false` | ошибка без причины |
| `ctx.skip(reason)` | пропуск с этой причиной |
| throw или reject | ошибка с её сообщением |
| `ctx.exit(code, reason)` | ошибка с завершением с кодом `code` |

`ctx.log`, `ctx.warn` и `ctx.error` пишут строки, относящиеся к вашей задаче. Это сообщения сборки, поэтому они идут в стандартный поток ошибок вместе со всеми остальными сообщениями сборки; стандартный вывод содержит только то, что пишет запущенная вами программа.

### Завершение с определённым статусом

`ctx.exit(code, …message)` завершает задачу ошибкой и просит сборку завершиться с кодом `code`. `0` вместо этого означает успех — поэтому для задачи, оборачивающей программу, это и есть всё решение об успехе или неудаче:

```ts
ctx.exit(ctx.exec("shellcheck", ["scripts/build.sh"]).exitCode);
```

Используйте это, когда статус несёт смысл, который потерялся бы в логическом значении. Храповик корректности, завершающийся с кодом 2, потому что анализатор упал, и с кодом 1, потому что нашёл новое нарушение, полезен, только если вызывающий может их различить; то же касается диагностики, завершающейся с 2 для «не поддерживается на этом хосте» и с 1 для «что-то найдено».

Код достигает процесса, только если именно ваша задача остановила сборку: если раньше упала другая задача, побеждает она. Код вне диапазона `0`-`255` сворачивается так, как его сообщает оболочка, и никогда не сворачивается в ноль.

## Запуск внешних инструментов

```ts
const result = ctx.exec("shellcheck", ["scripts/build.sh"], {
  cwd: "tools",            // relative paths resolve against the project root
  env: { CI: "1" },        // added on top of the inherited environment
  quiet: false,            // set true to keep the program's output out of the build log
  timeout: 30_000,         // milliseconds; the child is killed and the task fails
});

result.ok;        // exited zero
result.exitCode;  // 0, 3, …
result.stdout;    // captured up to 1 MiB, even when not quiet; the rest is streamed only
result.stderr;    // under the same cap
```

`exec` блокируется до завершения программы и передаёт её вывод на экран сборки по мере поступления, с привязкой к вашей задаче. Оболочка не используется: аргументы доходят до программы ровно в том виде, в каком вы их написали, поэтому ничто в имени файла или флаге не может быть истолковано как синтаксис оболочки. Голое имя команды ищется в `PATH`, с которым будет запущен дочерний процесс (том, что задаёт ваш `env`, если задаёт), а имя с разделителем разрешается относительно `cwd`. Отмена сборки завершает программу и всё, что она запустила. `timeout` ограничивает указанную программу; после её завершения поступающий вывод читается ещё одну секунду, а затем отбрасывается, поэтому фоновый процесс, оставивший открытым её вывод, не задерживает задачу — его не ждут и не завершают.

Доступно и всё, что уже есть у гостевого JavaScript, — `node:fs`, `node:child_process`, `fetch`, — поэтому используйте `exec`, когда вывод должен относиться к вашей задаче, и `node:child_process`, когда нужно то, чего `exec` не умеет. С этим связано одно правило: дочерний процесс, которому вы отдаёте терминал сборки (`stdio: "inherit"`), пишет туда, где рисуется индикатор прогресса, поэтому передавайте ему терминал через `ctx.foreground`. Дочернему процессу, чей вывод вы перенаправляете и печатаете сами, ничего не нужно: вывод вашей `console` проходит через экран, как любой другой.

### Передача терминала программе

```ts
const code = await ctx.foreground(
  () =>
    new Promise((resolve) => {
      const child = spawn(bin, args, { stdio: "inherit" });
      child.on("close", resolve);
    }),
);
```

Индикатор прогресса перестаёт рисоваться ровно на время выполнения функции и возвращается, как бы она ни завершилась, поэтому вывод программы и строки индикатора никогда не стирают друг друга. `async`-функция удерживает терминал, пока её промис не завершится, а `await ctx.foreground(...)` возвращает то, что вернула функция, и повторно выбрасывает то, что она выбросила.

Оборачивайте передачу, а не всю задачу: всё, что задача делает до запуска процесса, — разрешение путей, чтение файлов, вызовы `ctx.tools` — должно по-прежнему показывать прогресс. Используйте это, только когда что-то пишет в собственные дескрипторы сборки, то есть для дочернего процесса с унаследованным stdio или чего-то интерактивного, вроде REPL или отладчика. Вывод через `ctx.log`, `ctx.exec`, `ctx.tools` или собственную `console` задачи уже упорядочен относительно экрана.

## Встроенный инструментарий

Elide уже содержит компилятор Kotlin, `javac`, `jar`, Native Image, ktfmt и google-java-format. Задаче, которой нужен один из них, не нужно запускать ещё один Elide:

```ts
// Before: a new process per compile, diagnostics as text on a stream.
const compiled = ctx.exec("elide", ["kotlinc", "-d", ".dev/classes", "-cp", cp, "src/main/kotlin"]);
if (!compiled.ok) return ctx.fail(compiled.stderr);
ctx.exec("elide", ["jar", "--create", "--file", ".dev/lib/app.jar", "-C", ".dev/classes", "."]);

// After: the same tools, in-process, with structured results.
const result = ctx.tools.kotlinc({
  sources: ["src/main/kotlin"],                 // files, directories or globs, project-relative
  out: ".dev/classes",                          // a directory, or a path ending in `.jar`
  classpath: ctx.tools.classpath(),             // the resolved compile classpath, in order
  friendPaths: [".dev/classes-core"],           // whose `internal` declarations this compile may see
  args: ["-Xjvm-default=all"],                  // any further kotlinc flags, verbatim
});
if (!result.ok) {
  const first = result.diagnostics.find((d) => d.severity === "error");
  return ctx.fail(`${first?.file}:${first?.line}: ${first?.message}`);
}

ctx.tools.jar({
  out: ".dev/lib/app.jar",
  entries: [{ from: ".dev/classes" }, { from: "LICENSE", into: "META-INF" }],
  mainClass: "com.acme.MainKt",
  manifest: { "Implementation-Version": ctx.project.version ?? "dev" },
});
```

Каждый вызов блокируется до возврата инструмента и возвращает `{ ok, exitCode, tool, diagnostics }`. Диагностика компилятора и форматтера показывается в выводе сборки точно так же, как при встроенной компиляции, и возвращается в `diagnostics` в виде `{ severity, message, file, line, column }`; `jar` и `nativeImage` производят текст, который передаётся в журнал сборки с привязкой к вашей задаче (передайте `quiet: true`, чтобы его скрыть), и оставляют `diagnostics` пустым. Пути относительны проекту, если не абсолютны. Писать выходные данные можно куда угодно, но кешировать и восстанавливать можно только данные внутри каталога проекта, поэтому держите их там, если задача их объявляет.

- `kotlinc({ sources, out, classpath?, friendPaths?, args?, incremental?, plugins? })` — встроенный компилятор Kotlin. Плагины компилятора, которые манифест объявляет или включает (`kotlin.features`, `kotlin.plugins`, `mavenDependencies().kotlinPlugins`), применяются так же, как при встроенной компиляции, а runtime-библиотеки этих возможностей (например, Metro) добавляются в classpath вместе с ними: плагин без своей runtime-библиотеки приводит к ошибке компилятора, а не к бездействию. `plugins: false` запускает компилятор без плагинов — это то, что нужно исходникам только с аннотациями. `-jvm-target` не добавляется, если вы не передадите его в `args`.
- `javac({ sources, out, classpath?, processorPath?, args? })` — встроенный компилятор Java; `processorPath` включает обработку аннотаций.
- `jar({ out, entries, exclude?, date?, manifest?, mainClass?, quiet? })` — каждая запись — это каталог (его содержимое), файл или другой архив, записи которого берутся (`archive: true`), и попадает в корень архива или под `into`. `Manifest-Version` и `Created-By` записываются всегда; `mainClass` становится `Main-Class`.
- `nativeImage({ args, cwd?, quiet? })` — встроенный драйвер Native Image; относительные пути в `args` отсчитываются от `cwd`, по умолчанию — корня проекта. Сборщик работает как дочерняя JVM, привязанная к собственному JDK Elide.
- `format({ tool, files, check?, args? })` — встроенные форматтеры: `tool` — это `"ktfmt"` для Kotlin или `"javaformat"` для google-java-format. `files` — файлы, каталоги или glob-шаблоны, суженные до того, что принимает форматтер (`.kt`/`.kts` или `.java`); если ничего не совпало, вызов успешен и ничего не делает. Файлы переписываются на месте или, с `check: true`, только перечисляются, и вызов завершается ошибкой, если какой-либо файл изменился бы. В обоих случаях `changed` перечисляет абсолютные пути неотформатированных файлов, и каждый приходит как диагностика: предупреждение при `check`, информационное сообщение при перезаписи, ошибка для файла, который не удалось прочитать или разобрать. `args` передаёт форматтеру дополнительные флаги, например `["--google-style"]`.
- `classpath({ sourceSet?, usage? })` — разрешённый classpath зависимостей в виде абсолютных путей по порядку. `usage` — это `"compile"` (по умолчанию; то, что видят задачи компиляции), `"runtime"`, `"test"` (classpath выполнения тестового набора), `"processor"` или `"modules"`. Он возвращает то, что разрешила текущая сборка, поэтому задаче, которая его читает, следует указать `dependsOn: "maven-dependencies"`.

`ctx.tools.version` — версия этих форм параметров и результатов, сейчас `1`. Она меняется только при несовместимом изменении; новый необязательный параметр или поле результата её не меняет. Расширение, которое должно работать с несколькими версиями Elide, ветвится по ней.

Компиляторы работают внутри процесса только через встроенный инструментарий. Манифест, выбирающий собственный или управляемый `kotlin.toolchain` либо внешний `jvm.java.compiler.mode`, получает от `ctx.tools.kotlinc` / `ctx.tools.javac` ошибку с указанием задачи и инструмента; уберите этот выбор или запустите тот инструментарий как программу через `ctx.exec`. То же относится к дистрибутиву, собранному без инструментария JVM.

### Переупаковка архива

`jar` читает существующий архив как источник, поэтому урезать зависимость и воспроизводимо проштамповать её можно одним вызовом внутри процесса, а не конвейером `unzip` / `rm -rf` / `jar`:

```ts
ctx.tools.jar({
  out: ".dev/lib/compiler-slim.jar",
  entries: [{ from: "libs/compiler.jar", archive: true }, { from: ".dev/patched" }],
  exclude: ["com/intellij/**", "META-INF/*.SF"],
  date: "2024-01-01T00:00:00Z",
});
```

`archive: true` никогда не выводится из расширения файла: без него `.jar`, указанный в `from`, добавляется как один файл. Собственный `META-INF/MANIFEST.MF` исходного архива отбрасывается — результат несёт манифест, описанный `manifest` и `mainClass`, а не манифест источника, — а `into` переносит всё остальное под новый корень. Элемент, путь которого после `into` оказался бы вне архива (`../x` или абсолютный `/x`), приводит к ошибке задачи, а не записывается; то же относится к `into`, выходящему наружу.

Glob-шаблоны `exclude` сравниваются с путём, который запись получила бы *внутри архива*, после переноса через `into`. Это пути, а не имена пакетов, поэтому дерево пакета — это `"kotlin/**"`, а вид файлов — `"META-INF/*.SF"`. Шаблон в параметрах применяется ко всем записям независимо от источника; шаблон на записи применяется только к ней, в дополнение к общим.

`date` ставит на каждую запись, включая манифест, указанную метку времени, а инструмент пишет файлы каждого корня в отсортированном порядке — поэтому переупаковка неизменных входных данных даёт побайтово идентичный результат, что и нужно для AOT- или дистрибутивной метки. Без него время записей не воспроизводимо: запись, переданная без изменений, сохраняет время изменения своих файлов, а элемент архива, запись с `into` и запись, затронутая исключением или коллизией, получают время вызова.

Записи упаковываются в порядке перечисления, и две из них могут претендовать на один путь в архиве: побеждает последняя, а сборка выдаёт предупреждение с путём и источником, откуда он взят. Ничего не объединяется: артефакт `Jar`, объявленный в манифесте, объединяет файлы `META-INF/services` своих корней, а `ctx.tools.jar` — нет, поэтому задача, которой нужно такое объединение, сама записывает объединённый файл и указывает его последним.
