Elide bringt einen Compiler, einen Maven-Resolver, einen Container-Assembler und einen Test-Runner mit. protoc, shellcheck oder was Ihr Projekt als Nächstes braucht, bringt es nicht mit. Mit einer Build-Erweiterung bringen Sie Ihrem Build ein solches Werkzeug bei – als JavaScript- oder TypeScript-Modul, das Elide auswertet, während es den Build konfiguriert.
Eine Erweiterung deklariert Tasks. Jeder wird zu einem echten Task im Graphen: Sie können ihn als Build-Ziel angeben, andere Tasks von ihm abhängen lassen, ihn einer Gruppe zuordnen und in elide build --inspect sehen.
Eine Erweiterung deklarieren
Tragen Sie sie relativ zur Projektwurzel in Ihr Manifest ein:
amends "elide:project.pkl"
name = "demo"
extensions {
"tools/protoc.ts"
}
Erweiterungen werden deklariert, nie entdeckt; eine laufende Erweiterung ist also immer etwas, das Ihr Projekt angefordert hat. Ein Pfad, der nicht existiert, lässt den Build fehlschlagen, statt stillschweigend nichts zu registrieren.
Ein Pfad wird gegen die Projektwurzel aufgelöst und muss innerhalb Ihres Quellbaums bleiben – der Projektwurzel oder, wenn Ihr Projekt Mitglied eines Workspace ist, der Workspace-Wurzel, damit Mitglieder eine Datei oberhalb ihrer eigenen Wurzeln teilen können (../tools/protoc.ts). Ein absoluter Pfad außerhalb des Baums, ein Pfad, der aus ihm herausführt, und ein Symlink, der aus ihm herauszeigt, werden abgelehnt und jeweils wie jede andere fehlerhafte Deklaration gemeldet.
Eine Deklaration benennt eines von zwei Dingen, und Elide entscheidet allein anhand der Dateiendung, welches: ein ES-Modul – .ts, .mts, .js, .mjs, .jsx, .tsx – oder ein Paketarchiv .elx (siehe Eine Erweiterung paketieren). .cjs wird nicht akzeptiert: Der Vertrag ist ein Default-Export, den ein CommonJS-Skript nicht deklarieren kann, und eine .js-Erweiterung wird immer als Modul gelesen, was auch immer die nächstgelegene package.json sagt. Alles andere lässt den Build fehlschlagen, statt geraten zu werden.
Ein Eintrag muss überhaupt kein Pfad sein: Er kann ein Paket benennen, das von einer URL, aus npm oder aus Maven geladen wird. Siehe Eine Erweiterung laden.
Eine Erweiterung schreiben
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);
},
);
};
Dann:
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
Die Typen werden in @elide-dev/types als ElideBuild, ElideBuildTaskContext und ElideBuildTaskOptions ausgeliefert.
Der Default-Export Ihres Moduls läuft, während der Build konfiguriert wird, und darf async sein. Die Aktion jedes Tasks läuft, wenn der Task läuft, und darf ebenfalls async sein; der Build wartet in beiden Fällen.
Eine Erweiterung paketieren
Eine Erweiterung, die einer einzelnen Datei entwachsen ist, wird als Archiv ausgeliefert: ein Zip mit einem Manifest namens extension.json an seiner Wurzel, benannt mit .elx. Der Name ist keine Dekoration – die Dateiendung der Deklaration sagt Elide, dass es ein Paket vor sich hat, und .elx ist die einzige, die das tut; tools/protoc.elx wird also geladen, dieselben Bytes als tools/protoc.zip nicht.
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"
}
Das Manifest des Archivs verlangt name und type. type ist source – die einzige Art, die es gibt –, und ein source-Paket muss außerdem einen entrypoint nennen, einen Pfad relativ zur Archivwurzel; einer, der aus dem Archiv herauszeigt, wird abgelehnt. Alles andere sind Metadaten, die Elide Ihnen zurückmeldet, und unbekannte Schlüssel werden ignoriert, sodass ein Paket eigene Schlüssel mitführen kann.
Das Paket bauen
Deklarieren Sie das Paket als Artefakt, und elide build erzeugt es:
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." }
}
}
Das registriert einen Task namens package-extension-tasks, den ein einfaches elide build ausführt und der .dev/artifacts/tasks.elx schreibt. Die obige extension.json wird für Sie geschrieben: name, version und description fallen auf die des Projekts zurück, und type ist immer source.
entrypoint ist das Modul, dessen Default-Export Ihre Tasks deklariert. srcs sind die Module, die es importiert, als Pfade oder Glob-Muster relativ zur Projektwurzel; der Einstiegspunkt selbst wird immer paketiert. Dateien behalten die Pfade, die sie in Ihrem Projekt haben, sodass relative Importe im Archiv genauso aufgelöst werden wie in Ihrem Checkout. Jede paketierte Datei muss nach dem Auflösen ihrer Links innerhalb des Projekts liegen: Ein Symlink, der aus dem Projekt herauszeigt, lässt den Build fehlschlagen, statt sein Ziel auszuliefern.
Sie können das Zip natürlich auch selbst bauen, aus dem Verzeichnis mit dem Manifest, damit extension.json an der Wurzel landet:
cd protoc-tasks && zip -r ../tools/protoc.elx .
Ein Quellpaket
"type": "source" bedeutet, dass das Archiv Ihren Quellcode enthält, und die Dateiendung des Einstiegspunkts entscheidet, wie er geladen wird – genau wie bei einer Datei, die Sie direkt deklarieren. Ein Einstiegspunkt .ts, .tsx, .mts, .js, .jsx oder .mjs wird als Gastmodul ausgewertet, und weil Elide das Archiv vor dem Auswerten entpackt, werden relative Importe innerhalb des Pakets (import { flags } from "./lib/flags.ts") aufgelöst.
Auf dem Weg hinein wird nichts gebündelt oder kompiliert: Ein Paket liefert die Quellen aus, die Sie geschrieben haben, und Sie schreiben dieselbe Erweiterung, ob sie als einzelne Datei oder als Archiv reist – derselbe Default-Export, dasselbe task(...), dasselbe ctx.
Ein Paket deklarieren
Wie eine lokale Erweiterung – ein Pfad, mit Einstellungen, falls sie welche annimmt – oder als etwas, das geladen wird:
extensions {
"tools/shellcheck.elx"
new {
from = "tools/protoc.elx"
config { greeting = "hei" }
}
}
Elide entpackt das Archiv in Ihr Build-Verzeichnis, nach .dev/extensions/packages/<archive>-<hash>/contents/, und entpackt es erneut, sobald sich das Archiv ändert. Ein Eintrag, der außerhalb dieses Verzeichnisses geschrieben würde, wird abgelehnt statt entpackt.
Was --inspect meldet
elide build --inspect gibt einen Abschnitt Extensions: aus, der alles abdeckt, was Ihr Manifest deklariert hat. Für jede Erweiterung zeigt er die Deklaration, die Sie geschrieben haben, Name und Version des Pakets, seine Beschreibung, um welche Art Erweiterung es sich handelt – JavaScript/TypeScript module oder packaged extension –, seine Lizenz, sein Repository und die Namen der Tasks, die es deklariert hat.
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
Die Verteilung des Archivs liegt bei Ihnen: Hängen Sie es an ein Release, liefern Sie es über eine URL aus oder veröffentlichen Sie es als npm-Paket mit dem, was Sie ohnehin nutzen. Wie auch immer es reist, ein Projekt nutzt es auf dieselbe Weise – siehe Eine Erweiterung laden.
Eine Erweiterung laden
Eine Erweiterung muss nicht in Ihrem Projekt liegen. Eine Deklaration, die mit einem von drei reservierten Schemata beginnt, benennt etwas zum Herunterladen:
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" }
}
}
Nur url:, npm: und maven: sind reserviert. Alles andere ist ein Pfad, sodass C:\tools\ext.ts weiterhin bedeutet, was es sagt, und ein Schema, das Elide später hinzufügt, nicht ändern kann, wie ein bereits bauendes Manifest gelesen wird.
Nach dem Download ist nichts anders. Ein geladenes .elx wird als Paket geladen und ein geladenes .ts als Gastmodul – entscheidend ist, wozu es aufgelöst wurde, nicht wie es ankam. Eine url: muss eine absolute https-URL sein; unverschlüsseltes http wird abgelehnt, außer zu einem Loopback-Host, damit Sie gegen etwas Lokales testen können. Weiterleitungen werden verfolgt – bis zu fünf Sprünge –, und jeder Sprung unterliegt derselben Regel, sodass eine Weiterleitung einen Loopback-Abruf nicht unverschlüsselt auf eine andere Maschine tragen kann.
Versionen sind festgelegt
Eine entfernte Version benennt genau ein Release. npm:tasks und maven:com.acme:tasks lehnt das Manifest-Schema ab, weil sie keines nennen; npm:tasks@^1.2, npm:tasks@latest und maven:com.acme:tasks:1.2.3-SNAPSHOT, weil sie mehr als eines nennen.
Das liegt am Cache. Eine geladene Erweiterung wird unter dem gespeichert, was Sie deklariert haben; eine wandernde Version würde also entweder bei jedem Build eine Anfrage an die Registry kosten – was --offline unmöglich machte – oder stillschweigend bei dem einfrieren, was Ihr erster Build gerade sah.
Wo sie zwischengespeichert wird
Geladene Artefakte landen in .dev/dependencies/extensions/remote/, neben Ihrem aufgelösten Maven-Repository und ebenso von einem ganzen Workspace geteilt. Weil die Deklaration festgelegt ist, ist der Cache-Inhalt nie veraltet: Nur der erste Build geht ins Netz, und elide build --offline funktioniert ab dann. Eine Deklaration, die noch nicht geladen wurde und nicht geladen werden kann, sagt das:
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.
Jeder Digest, den die Quelle veröffentlicht, wird geprüft: dist.integrity oder dist.shasum bei npm und die .sha1-Begleitdatei bei Maven. Ein Repository, das ein Artefakt ohne Digest ausliefert, ist in Ordnung; eines, das beides ausliefert und sich selbst widerspricht, lässt den Build fehlschlagen.
Die Bytes festlegen
Eine Versionsfestlegung sagt, welches Release läuft; eine integrity-Festlegung sagt, welche Bytes. Schreiben Sie sie als Subresource Integrity – sha256-<base64> oder sha512-<base64> – neben die Quelle, zu der sie gehört:
extensions {
new {
from = "url:https://downloads.example.com/protoc-tasks-1.4.0.elx"
integrity = "sha512-Ln7jRoUMYMK7TBZ0bNyPmrHDCgAsQ3bIt5nZeUuzpuGqBbw1zYWTPdA2tRbClC2CoRP3UExpHnCw+5kZLgP0lQ=="
}
}
Einer url: wird nur aufgrund von TLS und ihres Hosts vertraut; erst die Festlegung macht sie reproduzierbar. Sie wird geprüft, bevor der Download zwischengespeichert wird, und erneut bei jedem Cache-Treffer, und ein npm-Paket läuft aus einem Baum, der aus genau diesen verifizierten Bytes entpackt wurde – weder der Server noch der gemeinsame Cache des Workspace können also hinter Ihrem Rücken ändern, was läuft. Eine zwischengespeicherte Datei, die nicht mehr zur Festlegung passt, wird neu heruntergeladen und ersetzt; unter --offline schlägt der Build stattdessen fehl und nennt die zwischengespeicherte Datei. Eine Festlegung kommt zu dem hinzu, was die Registry veröffentlicht, statt es zu ersetzen: Ein npm- oder Maven-Digest wird weiterhin geprüft.
Nur eine geladene Quelle akzeptiert eine Festlegung. Einen Pfad in Ihrem Projekt festzulegen wird abgelehnt – seine Bytes sind ohnehin das, was Ihr Baum enthält.
Ein Download ist in Größe und Zeit begrenzt: Eine Antwort über 64 MiB schlägt fehl, ebenso ein Host, der antwortet und dann stockt, statt Ihre Konfiguration hängen zu lassen.
npm-Pakete
Das Tarball wird einmal für den Workspace zwischengespeichert, in das Build-Verzeichnis Ihres Projekts nach .dev/extensions/npm/<name>/<version>/contents/ entpackt und von seinem Einstiegspunkt geladen: dem, den extension.json nennt, falls das Paket eine mitbringt, sonst main aus package.json, sonst index.js. Ein Paket mit einer extension.json ist ein Paket im vollen Sinn und wird genau wie ein .elx gelesen – eines, das ungültig ist oder einen type nennt, den diese Elide-Version nicht kennt, lässt den Build fehlschlagen, statt auf main zurückzufallen.
Die Auflösung durchläuft drei Schritte in dieser Reihenfolge: dependencies.npm.registry, wenn Ihr Manifest eines setzt, dann $ELIDE_PKGST_NPM_ENDPOINT – dieselbe Variable, die Elides eigener npm-Resolver liest –, und wenn beides fehlt, Elides Proxy npm.pkg.st oder die öffentliche Registry unter registry.npmjs.org, wenn Sie --use-pkgst=false übergeben.
dependencies {
npm {
registry = "https://npm.internal.example.com"
}
}
Maven-Artefakte
Die Koordinate wird aus den Repositories geladen, die Sie bereits deklarieren – dependencies.maven.repositories, dazu Elides Proxy und Maven Central, sofern enableDefaultRepositories nicht abgeschaltet ist –, mit den Zugangsdaten, die diese Repositories mitführen. Zugangsdaten gehen nur an Schema, Host und Port des Repositorys selbst; eine Weiterleitung anderswohin wird ohne sie verfolgt. Das übliche [:packaging[:classifier]] steht zwischen Artefakt und Version, und eine Koordinate ohne Packaging erhält elx: maven:com.acme:tasks:1.4.0 lädt tasks-1.4.0.elx, genau wie maven:com.acme:tasks:elx:1.4.0. Ein Packaging, das Elide nicht laden kann (maven:com.acme:tasks:zip:1.4.0), wird wie jede nicht ladbare Deklaration abgelehnt.
Repositories werden der Reihe nach versucht. Eines, das mit einem Fehler, einer zu großen oder stockenden Antwort oder einer abgelehnten Weiterleitung antwortet, wird zugunsten des nächsten übergangen; eine veröffentlichte .sha1, die nicht zum Artefakt passt, lässt den Build fehlschlagen.
Es wird ein Artefakt geladen und kein Abhängigkeitsgraph aufgelöst, genau wie bei einem Archiv, das Sie per Pfad deklarieren. Eine Erweiterung, die Bibliotheken braucht, liefert sie in ihrem Paket mit.
Eine Erweiterung konfigurieren
Eine Erweiterung, die Einstellungen annimmt, wird als Block statt als bloßer Pfad deklariert und trägt diese mit sich. Elide interpretiert sie nicht:
extensions {
"tools/shellcheck.ts"
new {
from = "tools/protoc.ts"
config {
version = "25.1"
plugins = new Listing { "grpc" }
}
}
}
Dieser Block erreicht diese Erweiterung – und nur diese – als config, während sie Tasks deklariert und während diese laufen:
export default ({ task, config }: ElideBuild) => {
task("protoc", { description: `protoc ${config.version}` }, (ctx) => {
const { version, plugins } = ctx.config;
});
};
Eine als bloßer Pfad deklarierte Erweiterung liest ein leeres config; für einen Standardwert genügt also die Prüfung, ob eine Einstellung undefined ist.
Eine Erweiterung, die ihre Einstellungen typisiert und dokumentiert haben möchte, liefert ein eigenes Pkl-Modul mit, das Ihr Projekt hineinkopiert, importiert und ausfüllt. Ein Manifest importiert lokale Dateien, die Pkl-Standardbibliothek und Elides eigenes Schema – nie eine package://- oder andere Netzwerk-URI –, also liegt das Modul in Ihrem Projekt:
import "tools/Protoc.pkl"
extensions {
new {
from = "tools/protoc.ts"
config = new Protoc.Settings { version = "25.1" }
}
}
Einträge sind nach Pfad eindeutig, wie auch immer Sie sie schreiben; dieselbe Erweiterung kann also nicht zweimal deklariert – oder konfiguriert – werden.
Schreiben Sie eine Liste in einem Block als Listing (oder List("grpc")). Inline-Elemente – plugins { "grpc" } – kämen bei Ihrer Erweiterung nach Position indiziert statt als Liste an; das Manifest lehnt sie daher ab und nennt Ihnen die funktionierende Schreibweise.
Einstellungen sind Daten. Ein Wert kann ein String, ein Boolean, eine Zahl, eine Duration, eine DataSize (die Ihre Erweiterung als Byte-Anzahl erreicht) oder ein Regex sein, oder ein Block, Listing, List, Set, Map oder Pair davon. Ein Bytes, eine Klasse, ein Typalias oder eine Funktion ist ein Manifest-Fehler, der die Stelle nennt, etwa config.flags.blob.
Task-Optionen
| Option | Bedeutung |
|---|---|
description | Wird von elide build --inspect angezeigt. |
dependsOn | Task-Namen, die zuerst abgeschlossen sein müssen; ein einzelner Name darf ohne Array angegeben werden. |
before | Tasks, denen dieser vorausgehen muss: ein Task-Name oder deps/compile/test für jeden Task dieser Gruppe. |
group | deps, compile oder test, um einer eingebauten Gruppe beizutreten; jeder andere Name legt eine eigene an. |
default | Ob ein bloßes elide build diesen Task ausführt. Standard: true. |
inputs | Projektrelative Pfade oder Globs, deren Inhalt entscheidet, ob der Task erneut läuft. |
outputs | Projektrelative Pfade oder Globs, die der Task schreibt. |
cache | Ob der Build-Cache die Ausgaben dieses Tasks archivieren darf. Standard: true. |
options | Kommandozeilen-Flags, die dieser Task akzeptiert (siehe unten). |
inputs und outputs sind ein Vertrag: Deklarieren Sie beide, überspringt Elide den Task, wenn sich nichts geändert hat (siehe Build-Tasks); deklarieren Sie eines oder keines, läuft er bei jedem Build. Ihre Erweiterung zählt ebenfalls als Eingabe – die Datei selbst und jedes Modul, das sie per relativem Pfad importiert –, sodass eine Änderung am Einstiegspunkt oder an einem seiner Helfer die deklarierten Tasks erneut ausführt.
Ein Task, der beides deklariert hat, wird außerdem wie Elides eigene zwischengespeichert: Seine Ausgaben werden bei Erfolg archiviert und in jedem späteren Build wiederhergestellt, dessen Eingaben denselben Fingerprint haben, auch in einem frischen Checkout. Setzen Sie cache: false, um die Aktualitätsprüfung zu behalten und das Archiv zu überspringen – die richtige Wahl, wenn Ihre Ausgaben von etwas abhängen, das Ihre inputs nicht nennen.
Womit eine Deklaration gestempelt wird, wird aus Modus und Inhalt jeder genannten Datei gehasht, und Ihre Aktion liest dieselben Dateien und dieselben Stempel über ctx.inputs und ctx.outputs – siehe Die Dateien, die eine Deklaration nennt.
Flags, die Ihr Task akzeptiert
Ein Task, der ein Flag liest, sollte das deklarieren. Deklarierte Flags werden von elide build --inspect und elide build <task> --help ausgegeben und geprüft, bevor irgendetwas läuft – ein Tippfehler wird also gemeldet statt stillschweigend verworfen:
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
},
);
| Feld | Bedeutung |
|---|---|
name | Das Flag, ohne Bindestriche und in kebab-case: "dry-run" ist --dry-run. |
description | Wird von --inspect und --help angezeigt. |
arity | "none" ist ein bloßes Flag, "required" verlangt --flag=value, "optional" akzeptiert beides. Standard: "none". |
repeatable | Ob das Flag mehrfach übergeben werden darf. Standard: false. |
negatable | Ob --no-<name> akzeptiert wird, gelesen als "false". Standard: false. |
default | Worauf ein Lesezugriff zurückfällt, wenn das Flag nicht übergeben wurde. |
keyed | Ob der übergebene Wert des Flags bestimmt, was der Task erzeugt. Standard: true. |
--inspect und --help geben die Deklaration so aus, wie sie getippt werden muss; die drei obigen lauten also --channel=<value>, --tag=<value> (repeatable) und --sign (or --no-sign) [default: true].
Ein Flag, das nichts deklariert, lässt den Build fehlschlagen, mit dem nächstliegenden deklarierten Namen, sofern es einen gibt; ebenso ein Wert für ein bloßes Flag, ein erforderliches Flag ohne Wert und die Wiederholung eines Flags, das nur einen Wert annimmt. Elides eigene Flags – --verbose, --project, --offline und die übrigen – werden immer akzeptiert.
ctx.option, ctx.flag und ctx.values lesen über die Deklaration; erst dadurch sind --no-sign und ein Standardwert überhaupt lesbar, denn beides sagt eine Kommandozeile nicht von selbst. Ein Name, den Ihr Task nicht deklariert hat, wird weiterhin so aus der Kommandozeile gelesen, wie sie geparst wurde.
Ein deklariertes Flag gehört zu dem, was entscheidet, ob Ihr Task erneut läuft: Die aufgelösten Werte werden neben Ihren inputs festgehalten, sodass ein Build unter --channel=stable nie als aktuell gemeldet wird – noch das erhält, was --channel=canary zwischengespeichert hat – gegenüber einem, der unter dem anderen lief. Setzen Sie keyed: false bei einem Flag, das ändert, wie gesprächig der Task ist, nicht was er schreibt; sonst verfehlt jeder Build, der es anders schreibt, grundlos den Cache. Ein Flag, das Ihre Aktion liest, ohne es zu deklarieren, entscheidet gar nichts.
Vor einem anderen Task laufen
dependsOn nennt, was zuerst laufen muss. before nennt, was warten muss – einschließlich Tasks, die Sie nicht deklariert haben; genau das braucht ein Codegen-Task:
task(
"generate-sources",
{ before: "compile", default: false, outputs: ["src/main/generated"] },
(ctx) => { ... },
);
compile, deps und test stehen für jeden Task dieser Gruppe; genau das muss „vor der Kompilierung“ bedeuten: Die echten Schlüssel sind pro Sprache und pro Source-Set (compile-kotlin-main, compile-java-test). Jeder andere Name ist der eines einzelnen Tasks, und ein Name, den nichts registriert hat, lässt den Build fehlschlagen, statt stillschweigend nichts auszuführen. In einem Workspace wählt eine Erweiterung, die ein Mitglied deklariert, nur die Tasks dieses Mitglieds aus, während eine, die das Wurzelmanifest deklariert, die Gruppe über alle Mitglieder hinweg auswählt.
Eine solche Kante zieht Ihren Task außerdem in jeden Build, der den Task ausführt, dem er vorausgeht – elide build compile führt oben zuerst generate-sources aus, obwohl dieser default abgewählt hat.
Inkrementelle Builds
Ein laufender Task erfährt, was der Build als veraltet befunden hat, und kann Werte für seinen nächsten Lauf aufbewahren:
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 nennt nur die Deklarationen, die sich geändert haben; bei einem ersten Lauf wird alles aufgeführt, was Sie deklariert haben. ctx.state hält Strings, Zahlen, Booleans, null, Arrays und einfache Objekte, nach Namen geordnet, und get antwortet null für einen Schlüssel, unter dem nichts gespeichert ist. Eine Funktion, ein Symbol und undefined haben keine Speicherform, und ein Wert, der sich selbst enthält, wird abgelehnt statt verfolgt. Ein fehlgeschlagener Lauf verwirft den Zustand des Tasks vollständig, einschließlich dessen, was frühere Läufe festgehalten haben.
Was sich ein Task merkt, ist eine Notiz, keine Eingabe: Es entscheidet nie, ob der Task erneut läuft, und verändert nie seinen Cache-Schlüssel.
Die Dateien, die eine Deklaration nennt
ctx.inputs und ctx.outputs sind die Deklarationen selbst, aufgelöst und gestempelt – derselbe Durchlauf und derselbe Digest, mit dem der Build entschieden hat, ob Ihr Task aktuell war:
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() listet jede reguläre Datei, die die Deklarationen nennen, projektrelativ und mit / getrennt, sortiert und ohne Duplikate. Jeder Eintrag trägt path, absolute, size, modified und stamp – ein SHA-256 über Modus und Inhalt dieser Datei. stamp() ist der Digest über die ganze Menge.
Beide akzeptieren eigene Muster, die nicht die deklarierten sein müssen: ctx.inputs.files("proto/api/**/*.proto") grenzt eine Auflistung ein, und ctx.inputs.stamp("proto") ist genau der Fingerprint, den der Build für diese Deklaration festgehalten hat. Beide werden beim Aufruf aufgelöst; ctx.outputs nach dem Schreiben Ihrer Ausgaben zu lesen beschreibt also, was Sie gerade erzeugt haben.
Stempel sind inhaltsadressiert: Eine Änderung, die Größe und Änderungszeit einer Datei unberührt lässt, ändert sie dennoch und führt Ihren Task erneut aus. ctx.dirty nennt eine Deklaration als Ganzes; ein in ctx.state aufbewahrter Stempel pro Datei, wie oben, ist der Weg, auf dem ein Task erkennt, welche seiner Dateien sich bewegt haben.
Deklarationen, die der Build berechnen muss
Manches, was entscheidet, ob ein Task aktuell ist, lässt sich nicht in die Deklaration schreiben: ein generierter Baum, ein Classpath, den ein anderer Task aufgelöst hat, die Version des Werkzeugs, das Sie gleich ausführen. Geben Sie dem Task einen prepare-Hook und deklarieren Sie es dort:
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`);
},
);
Was ein Hook hinzufügt, ist eine Deklaration wie jede andere: Ein Muster wird gestempelt, von ctx.dirty.inputs genannt und genau wie ein deklariertes in den Cache-Schlüssel des Tasks gehasht. ctx.inputs.value(name, value) deklariert eine Eingabe, die keine Datei ist – alles, was der Build berechnen, aber nicht per stat abfragen kann –, und eine Änderung daran führt den Task erneut aus und verschlüsselt sein Archiv anders; ctx.dirty.values nennt, welche sich geändert hat. Ein Task, dessen Deklaration so vervollständigt wird, wird wie jeder andere verfolgt, zwischengespeichert und als aktuell übersprungen.
Der Hook läuft bei jedem Build, an dem Ihr Task beteiligt ist, auch bei denen, in denen sich herausstellt, dass er nichts zu tun hat; beschränken Sie ihn also auf das Ableiten von Deklarationen: Er hat kein exec, keine tools und kein dirty – das ist ja die Antwort, die er mitberechnet –, und ctx.state ist dort schreibgeschützt. Gibt er false zurück, schlägt der Task fehl, bevor er läuft.
Ihre Aktion kann Ausgaben hinzufügen, die sie erst beim Schreiben kennt, mit demselben ctx.outputs.add(...); sie werden gestempelt und mit dem Rest archiviert, wenn sie zurückkehrt. Eingaben kann sie nicht hinzufügen – bis dahin hat der Build bereits entschieden, ob er sie ausführt; ein spätes ctx.inputs.add ist also ein Fehler statt einer Deklaration, die stillschweigend nichts bewirkt.
Einen Wert an einen anderen Task übergeben
Das Modul Ihrer Erweiterung wird einmal pro Build ausgewertet und von allen deklarierten Tasks geteilt; eine Modulvariable, die ein Task setzt, ist für den nächsten daher oft noch da – aber nichts garantiert das, und eine frische Auswertung beginnt bei null. Behandeln Sie den Modul-Scope als Cache, nie als Kanal. Was ein Task in ctx.state hält, ist der unterstützte Weg, einen Wert weiterzugeben:
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) liefert eine Sicht auf den Stand dieses Tasks – get, has und keys – und null, wenn er gar nichts veröffentlicht hat. Ein Task, den der Build als aktuell übersprungen oder aus dem Cache wiederhergestellt hat, antwortet mit dem, was er beim letzten Lauf behalten hat: Aktuell zu sein ist eine Aussage über das, was er erzeugt hat, und was er veröffentlicht hat, gehört dazu. null bedeutet, dass es nichts zu lesen gab – der Name ist kein Task, den eine Erweiterung deklariert hat, er war nicht in diesem Build, er ist fehlgeschlagen oder er ist noch nicht gelaufen –, während eine Sicht ohne Schlüssel ein Task ist, der nichts behalten hat. Lassen Sie Ihren Task von dem abhängen, den Sie lesen; ohne Kante ist „noch nicht gelaufen“ eine Race Condition.
Ein Wert wird als die Struktur übergeben, die er war, nicht als Text, und für das, was übergeben werden kann, gelten dieselben Regeln wie für ctx.state. Liest ein Task seinen eigenen Namen, erhält er, was er gerade hält.
Was Ihr Task erhält
Das an Ihre Aktion übergebene Objekt enthält dieselben Dinge, die der Build selbst liest:
project– Name, Version, Beschreibung, Wurzelverzeichnis und der Workspace, zu dem es gehört.manifest– Ihre aufgelösteelide.pklals gewöhnliche JavaScript-Daten. Das ist genau das, waselide project export --format=jsonschreibt;manifest.dependencies.maven.packages.map(...)funktioniert also wie erwartet.config– die Einstellungen, die Ihr Manifest für diese Erweiterung deklariert hat (siehe oben), leer, wenn es keine deklariert hat.layout–projectDir,buildDir,artifacts,stagingund Verwandte als absolute Pfade.option(name)/flag(name)/values(name)– die Flags, mit denen dieser Aufruf lief, gelesen über Ihre Deklaration (siehe oben);optionsist das rohe Parse-Ergebnis für alles, was Sie nicht deklariert haben.task– Ihre eigene Deklaration, wie der Build sie festgehalten hat, einschließlich der deklarierten Optionen.dirty/state– was sich seit dem letzten Lauf Ihres Tasks geändert hat, was er behalten hat und was andere Tasks in diesem Build festgehalten haben (siehe oben).
Ergebnisse melden
| In Ihrer Aktion | Was der Build festhält |
|---|---|
return oder true zurückgeben | Erfolg |
ctx.fail(reason) | Fehlschlag mit diesem Grund |
false zurückgeben | Fehlschlag ohne Grund |
ctx.skip(reason) | übersprungen mit diesem Grund |
| throw oder reject | Fehlschlag mit dessen Meldung |
ctx.exit(code, reason) | Fehlschlag mit Exit-Code code |
ctx.log, ctx.warn und ctx.error schreiben Zeilen, die Ihrem Task zugeordnet sind. Es sind Build-Meldungen und gehen daher wie alle anderen Build-Meldungen nach Standardfehler; die Standardausgabe enthält nur, was ein von Ihnen gestartetes Programm schreibt.
Mit einem bestimmten Status beenden
ctx.exit(code, …message) lässt den Task fehlschlagen und bittet den Build, mit code zu enden. 0 bedeutet stattdessen Erfolg; damit ist dies die gesamte Erfolgs- oder Fehlerentscheidung für einen Task, der ein Programm umhüllt:
ctx.exit(ctx.exec("shellcheck", ["scripts/build.sh"]).exitCode);
Greifen Sie dazu, wenn ein Status eine Bedeutung trägt, die ein Boolean zerstören würde. Eine Korrektheits-Ratsche, die mit 2 endet, weil der Analyzer abgestürzt ist, und mit 1, weil er einen neuen Verstoß gefunden hat, ist nur nützlich, wenn der Aufrufer beides noch unterscheiden kann; ebenso eine Diagnose, die mit 2 für „auf diesem Host nicht unterstützt“ und mit 1 für „etwas gefunden“ endet.
Der Code erreicht den Prozess nur, wenn Ihr Task derjenige ist, der den Build gestoppt hat – schlägt ein anderer Task zuerst fehl, gewinnt dieser. Ein Code außerhalb von 0-255 wird so umgebrochen, wie eine Shell ihn meldet, und nie auf null.
Externe Tools ausführen
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 blockiert, bis das Programm endet, und streamt seine Ausgabe beim Eintreffen in die Build-Anzeige, Ihrem Task zugeordnet. Es ist keine Shell beteiligt: Argumente erreichen das Programm genau so, wie Sie sie geschrieben haben; nichts in einem Dateinamen oder Flag kann als Shell-Syntax umgedeutet werden. Ein bloßer Befehlsname wird im PATH gesucht, mit dem der Kindprozess laufen wird – dem, den Ihr env setzt, falls es einen setzt –, und ein Name mit Trennzeichen wird gegen cwd aufgelöst. Ein Abbruch des Builds beendet das Programm und alles, was es gestartet hat. timeout begrenzt das genannte Programm; sobald es endet, wird noch eintreffende Ausgabe eine weitere Sekunde lang gelesen und dann verworfen, sodass ein Hintergrundprozess, der seine Ausgabe offen hält, Ihren Task nicht aufhält – auf diesen Prozess wird weder gewartet, noch wird er beendet.
Alles, was Gast-JavaScript ohnehin hat, steht ebenfalls zur Verfügung – node:fs, node:child_process, fetch –; greifen Sie also zu exec, wenn die Ausgabe Ihrem Task zugeordnet werden soll, und zu node:child_process, wenn Sie etwas brauchen, das es nicht abdeckt. Dazu gehört eine Regel: Ein Kindprozess, dem Sie das Terminal des Builds geben (stdio: "inherit"), schreibt dorthin, wo die Fortschrittsanzeige zeichnet; übergeben Sie ihm das Terminal daher mit ctx.foreground. Ein Kindprozess, dessen Ausgabe Sie selbst umleiten und ausgeben, braucht nichts – Ihre console-Ausgabe wird wie jede andere durch die Anzeige geleitet.
Einem Programm das Terminal geben
const code = await ctx.foreground(
() =>
new Promise((resolve) => {
const child = spawn(bin, args, { stdio: "inherit" });
child.on("close", resolve);
}),
);
Die Fortschrittsanzeige hört genau so lange auf zu zeichnen, wie die Funktion läuft, und kehrt zurück, wie auch immer sie endet – die Ausgabe des Programms und die Zeilen der Anzeige löschen sich also nie gegenseitig. Eine async-Funktion behält das Terminal, bis ihr Promise erledigt ist, und await ctx.foreground(...) liefert, was die Funktion geliefert hat, und wirft erneut, was sie geworfen hat.
Umhüllen Sie die Übergabe, nicht den ganzen Task: Alles, was Ihr Task vor dem Starten tut – Pfade auflösen, Dateien lesen, ctx.tools aufrufen –, sollte weiterhin Fortschritt anzeigen. Greifen Sie nur dazu, wenn etwas in die eigenen Deskriptoren des Builds schreibt, also ein Kindprozess mit geerbtem stdio oder etwas Interaktives wie eine REPL oder ein Debugger. Ausgabe über ctx.log, ctx.exec, ctx.tools oder die eigene console Ihres Tasks ist bereits mit der Anzeige linearisiert.
Die eingebaute Werkzeugkette nutzen
Elide bringt bereits einen Kotlin-Compiler, javac, jar, Native Image, ktfmt und google-java-format mit. Ein Task, der eines davon braucht, muss dafür kein weiteres Elide starten:
// 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" },
});
Jeder Aufruf blockiert, bis das Werkzeug zurückkehrt, und liefert { ok, exitCode, tool, diagnostics }. Compiler- und Formatter-Diagnosen werden in der Build-Ausgabe genau so angezeigt wie bei einer eingebauten Kompilierung und kommen in diagnostics als { severity, message, file, line, column } zurück; jar und nativeImage erzeugen Text, der Ihrem Task zugeordnet ins Build-Log gestreamt wird (übergeben Sie quiet: true, um ihn auszublenden), und lassen diagnostics leer. Pfade sind projektrelativ, sofern nicht absolut. Sie können Ausgaben überall hinschreiben, aber nur Ausgaben unter dem Projektverzeichnis können zwischengespeichert und wiederhergestellt werden; halten Sie sie also dort, wenn der Task sie deklariert.
kotlinc({ sources, out, classpath?, friendPaths?, args?, incremental?, plugins? })– der eingebaute Kotlin-Compiler. Compiler-Plugins, die Ihr Manifest deklariert oder aktiviert (kotlin.features,kotlin.plugins,mavenDependencies().kotlinPlugins), werden wie bei der eingebauten Kompilierung angewendet, und die Laufzeiten, die diese Features mitbringen (etwa die von Metro), kommen mit ihnen auf den Classpath – ein Plugin ohne seine Laufzeit lässt den Compiler fehlschlagen, statt nichts zu tun.plugins: falseführt den bloßen Compiler aus, was reine Annotationsquellen brauchen. Es wird kein-jvm-targethinzugefügt, sofern Sie keines inargsübergeben.javac({ sources, out, classpath?, processorPath?, args? })– der eingebaute Java-Compiler; einprocessorPathschaltet die Annotationsverarbeitung ein.jar({ out, entries, exclude?, date?, manifest?, mainClass?, quiet? })– jeder Eintrag ist ein Verzeichnis (sein Inhalt), eine Datei oder ein anderes Archiv, dessen Einträge übernommen werden (archive: true), und landet an der Archivwurzel oder unterinto.Manifest-VersionundCreated-Bywerden immer geschrieben;mainClasswird zuMain-Class.nativeImage({ args, cwd?, quiet? })– der eingebaute Native-Image-Treiber; relative Pfade inargsbeziehen sich aufcwd, standardmäßig die Projektwurzel. Der Builder läuft als Kind-JVM, festgelegt auf Elides eigenes JDK.format({ tool, files, check?, args? })– die eingebauten Formatter:toolist"ktfmt"für Kotlin oder"javaformat"für google-java-format.filessind Dateien, Verzeichnisse oder Globs, eingegrenzt auf das, was der Formatter akzeptiert (.kt/.ktsoder.java); passt nichts, ist der Aufruf erfolgreich und tut nichts. Dateien werden an Ort und Stelle umgeschrieben oder mitcheck: truenur gemeldet, wobei der Aufruf fehlschlägt, wenn sich eine Datei ändern würde. In beiden Fällen listetchangeddie absoluten Pfade der nicht formatierten Dateien, und jede kommt als Diagnose an: eine Warnung untercheck, ein Hinweis bei einer Umschreibung, ein Fehler für eine Datei, die nicht gelesen oder geparst werden konnte.argsreicht weitere Flags an den Formatter durch, etwa["--google-style"].classpath({ sourceSet?, usage? })– der aufgelöste Abhängigkeits-Classpath als absolute Pfade in Reihenfolge.usageist"compile"(Standard; was die Kompilier-Tasks sehen),"runtime","test"(der Laufzeit-Classpath des Test-Sets),"processor"oder"modules". Er liefert, was dieser Build aufgelöst hat; ein Task, der ihn liest, sollte alsodependsOn: "maven-dependencies"angeben.
ctx.tools.version ist die Version dieser Options- und Ergebnisformen – heute 1. Sie ändert sich nur bei einer inkompatiblen Änderung; eine neue optionale Option oder ein neues Ergebnisfeld ändert sie nicht. Eine Erweiterung, die mit mehr als einer Elide-Version laufen muss, verzweigt danach.
Die Compiler laufen nur über die eingebaute Werkzeugkette im selben Prozess. Ein Manifest, das eine eigene oder verwaltete kotlin.toolchain oder den externen jvm.java.compiler.mode auswählt, erhält von ctx.tools.kotlinc / ctx.tools.javac einen Fehler, der Task und Werkzeug nennt; entfernen Sie die Auswahl oder führen Sie diese Werkzeugkette mit ctx.exec als Programm aus. Dasselbe gilt für eine Distribution, die ohne die JVM-Werkzeugkette gebaut wurde.
Ein Archiv neu packen
jar liest ein bestehendes Archiv als Quelle; eine Abhängigkeit abzuspecken und reproduzierbar zu stempeln ist daher ein einziger Aufruf im selben Prozess statt einer Pipeline aus 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 wird nie aus der Dateiendung abgeleitet: Ohne es wird ein von from genanntes .jar als einzelne Datei hinzugefügt. Das eigene META-INF/MANIFEST.MF des Quellarchivs wird verworfen – das Ergebnis trägt das Manifest, das manifest und mainClass beschreiben, nie das der Quelle –, und into setzt alles andere unter eine neue Wurzel. Ein Element, dessen Pfad nach into außerhalb des Archivs landen würde (../x oder ein absolutes /x), lässt den Task fehlschlagen, statt geschrieben zu werden, ebenso ein into, das hinausführt.
exclude-Globs werden gegen den Pfad abgeglichen, den ein Eintrag im Archiv hätte, nachdem into ihn neu verwurzelt hat. Es sind Pfade, keine Paketnamen; ein Paketbaum ist also "kotlin/**" und eine Dateiart "META-INF/*.SF". Ein Glob in den Optionen gilt für jeden Eintrag unabhängig von seiner Quelle; ein Glob an einem Eintrag gilt zusätzlich nur für diesen Eintrag.
date stempelt jeden Eintrag, das Manifest eingeschlossen, auf diesen Zeitpunkt, und das Werkzeug schreibt die Dateien einer Wurzel in sortierter Reihenfolge – ein Neupacken unveränderter Eingaben ist also byte-identisch, was ein AOT- oder Distributions-Stempel braucht. Ohne date sind Eintragszeiten nicht reproduzierbar: Ein unverändert durchgereichter Eintrag behält die Änderungszeiten seiner Dateien, während ein Archivelement, ein Eintrag mit into und ein Eintrag, den ein Ausschluss oder eine Kollision berührt hat, die Zeit des Aufrufs tragen.
Einträge werden in der Reihenfolge paketiert, in der Sie sie auflisten, und zwei von ihnen können denselben Archivpfad beanspruchen: Der letzte gewinnt, und der Build warnt mit dem Pfad und der Quelle, aus der er übernommen wurde. Nichts wird zusammengeführt – ein im Manifest deklariertes Jar-Artefakt vereinigt die META-INF/services-Dateien seiner Wurzeln, ctx.tools.jar tut das nicht; ein Task, der diese Vereinigung braucht, schreibt die zusammengeführte Datei also selbst und listet sie zuletzt.