# Розширення збирання

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` — ні, тож завдання, якому потрібне таке об'єднання, само записує об'єднаний файл і вказує його останнім.
