Elide містить компілятор, резолвер Maven, збирач контейнерів і засіб запуску тестів. У ньому немає protoc, shellcheck чи того, що знадобиться вашому проєкту далі. Розширення збирання — спосіб навчити збирання працювати з таким інструментом: це модуль на JavaScript або TypeScript, який Elide обчислює під час конфігурування збирання.
Розширення оголошує завдання. Кожне стає справжнім завданням графа: його можна вказати як ціль збирання, зробити від нього залежним інше завдання, додати до групи й побачити в elide build --inspect.
Оголошення розширення
Укажіть його в маніфесті відносно кореня проєкту:
amends "elide:project.pkl"
name = "demo"
extensions {
"tools/protoc.ts"
}
Розширення оголошуються, а не виявляються, тож запуск розширення — завжди те, про що попросив ваш проєкт. Шлях, якого не існує, спричиняє помилку збирання, а не мовчазну відсутність реєстрації.
Шлях розв’язується відносно кореня проєкту й має залишатися всередині дерева вихідних файлів — кореня проєкту або, якщо проєкт входить до робочого простору, кореня робочого простору, щоб учасники могли спільно використовувати файл вище своїх коренів (../tools/protoc.ts). Абсолютний шлях поза деревом, шлях, що виходить із нього, і символьне посилання, що вказує назовні, відхиляються, і про кожен повідомляється так само, як про будь-яке інше некоректне оголошення.
Оголошення вказує на одне з двох, і Elide визначає, на що саме, лише за розширенням файлу: ES-модуль — .ts, .mts, .js, .mjs, .jsx, .tsx — або архів пакета .elx (див. Пакування розширення). .cjs не приймається: контракт — типовий експорт, а скрипт CommonJS не може його оголосити; файл .js завжди читається як модуль, що б не казав найближчий package.json. Усе інше спричиняє помилку збирання, а не спробу вгадати.
Запис узагалі не мусить бути шляхом: він може вказувати на пакет, який завантажується за URL, з npm або з Maven. Див. Завантаження розширення.
Написання розширення
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);
},
);
};
Потім:
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 як 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
{
"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 створить його:
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 опинився в корені:
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.
Оголошення пакета
Як локальне розширення — шлях, із налаштуваннями, якщо вони потрібні, — або як те, що слід завантажити:
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 тими засобами, якими вже користуєтеся. Хоч як він поширюється, проєкт використовує його однаково — див. Завантаження розширення.
Завантаження розширення
Розширення не мусить бути у вашому проєкті. Оголошення, що починається з однієї з трьох зарезервованих схем, указує на те, що треба завантажити:
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> — поруч із джерелом, до якого він належить:
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.
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 їх не інтерпретує:
extensions {
"tools/shellcheck.ts"
new {
from = "tools/protoc.ts"
config {
version = "25.1"
plugins = new Listing { "grpc" }
}
}
}
Цей блок потрапляє до цього розширення — і лише до нього — як config, поки воно оголошує завдання й поки вони виконуються:
export default ({ task, config }: ElideBuild) => {
task("protoc", { description: `protoc ${config.version}` }, (ctx) => {
const { version, plugins } = ctx.config;
});
};
Розширення, оголошене просто шляхом, читає порожній config, тож для типового значення досить перевірити, що налаштування дорівнює undefined.
Розширення, якому потрібні типізовані й задокументовані налаштування, постачає власний модуль Pkl, який ваш проєкт копіює до себе, імпортує й заповнює. Маніфест імпортує локальні файли, стандартну бібліотеку Pkl і власну схему Elide — але ніколи package:// чи інший мережевий URI, — тож модуль міститься у вашому проєкті:
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 пропустить завдання, якщо нічого не змінилося (див. Завдання збирання); оголосіть один або жодного — і завдання виконуватиметься під час кожного збирання. Ваше розширення теж вважається вхідними даними — сам файл і кожен модуль, який він імпортує за відносним шляхом, — тож редагування точки входу чи одного з її допоміжних модулів перезапускає оголошені завдання.
Завдання, яке оголосило обидва параметри, до того ж кешується так само, як завдання самого Elide: його вихідні дані архівуються в разі успіху й відновлюються під час будь-якого подальшого збирання з тим самим відбитком вхідних даних, зокрема у свіжій робочій копії. Задайте cache: false, щоб зберегти перевірку актуальності й відмовитися від архіву, — це правильний вибір, коли вихідні дані залежать від чогось, чого не вказано в inputs.
Відбиток оголошення обчислюється з режиму й вмісту кожного вказаного в ньому файлу, а ваша дія читає ті самі файли й ті самі відбитки через ctx.inputs і ctx.outputs — див. Файли, указані в оголошенні.
Прапорці, які приймає завдання
Завдання, що читає прапорець, має його оголосити. Оголошені прапорці виводять elide build --inspect і elide build <task> --help, і вони перевіряються до запуску будь-чого, тож про друкарську помилку повідомляється, а не мовчки відкидається:
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 указує, що має зачекати, — зокрема завдання, яких ви не оголошували; саме це потрібно завданню генерування коду:
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.
Інкрементальні збирання
Завдання, що виконується, дізнається, що збирання вважає застарілим, і може зберігати значення до наступного запуску:
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 — це самі оголошення, розв’язані й із відбитками: той самий обхід і той самий дайджест, за якими збирання вирішувало, чи актуальне ваше завдання:
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 і оголосіть це там:
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:
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 натомість означає успіх — тож для завдання, що обгортає програму, це й є все рішення про успіх чи невдачу:
ctx.exit(ctx.exec("shellcheck", ["scripts/build.sh"]).exitCode);
Використовуйте це, коли статус має зміст, який загубився б у логічному значенні. Храповик коректності, що завершується з кодом 2, бо аналізатор упав, і з кодом 1, бо знайшов нове порушення, корисний, лише якщо викликач може їх розрізнити; те саме стосується діагностики, що завершується з 2 для «не підтримується на цьому хості» і з 1 для «щось знайдено».
Код досягає процесу, лише якщо саме ваше завдання зупинило збирання: якщо раніше впало інше завдання, перемагає воно. Код поза діапазоном 0-255 згортається так, як його повідомляє оболонка, і ніколи не згортається в нуль.
Запуск зовнішніх інструментів
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 проходить через екран, як будь-який інший.
Передавання термінала програмі
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:
// 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:
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 — ні, тож завдання, якому потрібне таке об’єднання, само записує об’єднаний файл і вказує його останнім.