Aller au contenu

Extensions de build

Déclarez vos propres tâches de build en JavaScript ou TypeScript : écrivez, empaquetez, téléchargez et configurez des extensions, et lancez des outils externes.

Lire hors ligneelide help --lang fr tooling-build-extensions

Sur cette page

Elide embarque un compilateur, un résolveur Maven, un assembleur de conteneurs et un exécuteur de tests. Il n’embarque ni protoc, ni shellcheck, ni ce dont votre projet aura besoin ensuite. Une extension de build est la façon d’apprendre un outil de plus à votre build : un module JavaScript ou TypeScript qu’Elide évalue pendant qu’il configure le build.

Une extension déclare des tâches. Chacune devient une vraie tâche du graphe : vous pouvez la nommer comme cible de build, en faire dépendre une autre tâche, la placer dans un groupe et la voir dans elide build --inspect.

Déclarer une extension

Listez-la dans votre manifeste, relativement à la racine du projet :

pkl
amends "elide:project.pkl"

name = "demo"

extensions {
  "tools/protoc.ts"
}
utf-8 · pkl · Lignes : 7

Les extensions sont déclarées, jamais découvertes : en exécuter une est donc toujours quelque chose que votre projet a demandé. Un chemin qui n’existe pas fait échouer le build au lieu de n’enregistrer silencieusement rien.

Un chemin est résolu par rapport à la racine du projet et doit rester dans votre arborescence de sources : la racine du projet, ou la racine de l’espace de travail quand votre projet en est membre, afin que les membres puissent partager un fichier situé au-dessus de leurs propres racines (../tools/protoc.ts). Un chemin absolu hors de l’arborescence, un chemin qui en sort et un lien symbolique qui pointe à l’extérieur sont tous refusés, chacun signalé comme toute autre déclaration invalide.

Une déclaration désigne l’une de deux choses, et Elide décide laquelle d’après la seule extension de fichier : un module ES (.ts, .mts, .js, .mjs, .jsx, .tsx) ou une archive de paquet .elx (voir Empaqueter une extension). .cjs n’est pas accepté : le contrat est un export par défaut, qu’un script CommonJS ne peut pas déclarer, et un fichier .js est toujours lu comme un module, quoi qu’en dise le package.json le plus proche. Tout le reste fait échouer le build plutôt que d’être deviné.

Une entrée n’a même pas besoin d’être un chemin : elle peut désigner un paquet à télécharger depuis une URL, npm ou Maven. Voir Télécharger une extension.

En écrire une

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);
    },
  );
};
utf-8 · ts · Lignes : 18

Puis :

Terminal
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

Les types sont publiés dans @elide-dev/types sous les noms ElideBuild, ElideBuildTaskContext et ElideBuildTaskOptions.

L’export par défaut de votre module s’exécute pendant la configuration du build et peut être async. L’action de chaque tâche s’exécute quand la tâche s’exécute et peut aussi être async ; le build attend dans les deux cas.

Empaqueter une extension

Une extension qui dépasse un seul fichier est distribuée sous forme d’archive : un zip avec un manifeste nommé extension.json à sa racine, nommé .elx. Le nom n’est pas décoratif : c’est l’extension de fichier de la déclaration qui indique à Elide qu’il s’agit d’un paquet, et .elx est la seule qui le fasse ; tools/protoc.elx se charge donc, et les mêmes octets nommés tools/protoc.zip non.

protoc-tasks/
├── extension.json
├── tasks.ts
└── lib/
    └── flags.ts
utf-8 · texte · Lignes : 5
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"
}
utf-8 · json · Lignes : 11

Le manifeste de l’archive exige name et type. type vaut source (le seul type qui existe), et un paquet source doit aussi nommer un entrypoint, un chemin relatif à la racine de l’archive ; un point d’entrée qui pointe hors de l’archive est refusé. Tout le reste est constitué de métadonnées qu’Elide vous restitue, et les clés qu’il ne connaît pas sont ignorées : un paquet peut donc porter ses propres clés.

Construire le paquet

Déclarez le paquet comme artefact et elide build le produit :

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." }
  }
}
utf-8 · pkl · Lignes : 17

Cela enregistre une tâche nommée package-extension-tasks, exécutée par un simple elide build, qui écrit .dev/artifacts/tasks.elx. Le extension.json ci-dessus est écrit pour vous : name, version et description reprennent par défaut ceux du projet, et type vaut toujours source.

entrypoint est le module dont l’export par défaut déclare vos tâches. srcs sont les modules qu’il importe, sous forme de chemins ou de motifs glob relatifs à la racine du projet ; le point d’entrée lui-même est toujours empaqueté. Les fichiers gardent les chemins qu’ils ont dans votre projet, de sorte que les imports relatifs se résolvent dans l’archive exactement comme dans votre copie de travail. Chaque fichier empaqueté doit se trouver dans le projet une fois ses liens résolus : un lien symbolique qui pointe hors du projet fait échouer le build au lieu de distribuer sa cible.

Vous pouvez bien sûr toujours construire le zip vous-même, depuis le répertoire qui contient le manifeste, pour que extension.json se retrouve à la racine :

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

Un paquet source

"type": "source" signifie que l’archive contient vos sources, et l’extension de fichier du point d’entrée décide de la façon de le charger, exactement comme pour un fichier déclaré directement. Un point d’entrée .ts, .tsx, .mts, .js, .jsx ou .mjs est évalué comme module invité et, comme Elide décompresse l’archive avant de l’évaluer, les imports relatifs à l’intérieur du paquet (import { flags } from "./lib/flags.ts") se résolvent.

Rien n’est regroupé ni compilé au passage : un paquet distribue les sources que vous avez écrites, et vous écrivez la même extension qu’elle voyage sous forme de fichier unique ou d’archive : le même export par défaut, le même task(...), le même ctx.

Déclarer un paquet

Comme une extension locale (un chemin, avec des réglages si elle en accepte) ou comme quelque chose à télécharger :

pkl
extensions {
  "tools/shellcheck.elx"
  new {
    from = "tools/protoc.elx"
    config { greeting = "hei" }
  }
}
utf-8 · pkl · Lignes : 7

Elide décompresse l’archive dans votre répertoire de build, sous .dev/extensions/packages/<archive>-<hash>/contents/, et la décompresse de nouveau chaque fois que l’archive change. Une entrée qui serait écrite hors de ce répertoire est refusée au lieu d’être décompressée.

Ce que rapporte --inspect

elide build --inspect affiche une section Extensions: couvrant tout ce que votre manifeste a déclaré. Pour chaque extension, elle montre la déclaration que vous avez écrite, le nom et la version du paquet, sa description, le type d’extension (JavaScript/TypeScript module ou packaged extension), sa licence, son dépôt et les noms des tâches qu’elle a déclarées.

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
utf-8 · texte · Lignes : 5

La distribution de l’archive vous revient : joignez-la à une release, servez-la depuis une URL, publiez-la comme paquet npm avec ce que vous utilisez déjà. Quel que soit son mode de transport, un projet la consomme de la même façon ; voir Télécharger une extension.

Télécharger une extension

Une extension n’a pas besoin de résider dans votre projet. Une déclaration qui commence par l’un de trois schémas réservés désigne quelque chose à télécharger :

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" }
  }
}
utf-8 · pkl · Lignes : 9

Seuls url:, npm: et maven: sont réservés. Tout le reste est un chemin : C:\tools\ext.ts garde donc son sens, et un schéma qu’Elide ajouterait plus tard ne peut pas changer la lecture d’un manifeste qui construit déjà.

Une fois le téléchargement fait, rien ne change. Un .elx téléchargé se charge comme paquet et un .ts téléchargé comme module invité : c’est ce vers quoi il s’est résolu qui décide, pas la façon dont il est arrivé. Une url: doit être une URL https absolue ; le http en clair est refusé, sauf vers un hôte de loopback, prévu pour que vous puissiez tester contre quelque chose de local. Les redirections sont suivies (jusqu’à cinq sauts) et chaque saut est soumis à la même règle : une redirection ne peut donc pas emmener un téléchargement de loopback vers une autre machine en clair.

Les versions sont épinglées

Une version distante désigne exactement une release. Le schéma du manifeste refuse npm:tasks et maven:com.acme:tasks parce qu’ils n’en désignent aucune, et npm:tasks@^1.2, npm:tasks@latest et maven:com.acme:tasks:1.2.3-SNAPSHOT parce qu’ils en désignent plusieurs.

C’est le fait du cache. Une extension téléchargée est stockée sous ce que vous avez déclaré : une version mouvante coûterait donc soit un aller-retour vers le registre à chaque build (rendant --offline impossible), soit un gel silencieux sur ce que votre premier build a vu.

Où elle est mise en cache

Les artefacts téléchargés arrivent dans .dev/dependencies/extensions/remote/, à côté de votre dépôt Maven résolu, et sont partagés de la même façon par tout un espace de travail. Comme la déclaration est épinglée, le cache n’est jamais périmé : seul le premier build accède au réseau, et elide build --offline fonctionne dès lors. Une déclaration qui n’a pas été téléchargée et ne peut pas l’être le signale :

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.
utf-8 · texte · Lignes : 2

Toute empreinte publiée par la source est vérifiée : dist.integrity ou dist.shasum pour npm, et le fichier .sha1 associé pour Maven. Un dépôt qui sert un artefact sans empreinte est accepté ; un dépôt qui sert les deux et se contredit fait échouer le build.

Épingler les octets

Une version épinglée dit quelle release s’exécute ; un integrity épinglé dit quels octets. Écrivez-le au format Subresource Integrity (sha256-<base64> ou sha512-<base64>) à côté de la source à laquelle il appartient :

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

Une url: n’est garantie que par TLS et par son hôte : c’est l’épinglage qui la rend reproductible. Il est vérifié avant la mise en cache du téléchargement, puis à chaque accès au cache, et un paquet npm s’exécute depuis une arborescence extraite de ces mêmes octets vérifiés : ni le serveur ni le cache partagé de l’espace de travail ne peuvent changer ce qui s’exécute à votre insu. Un fichier en cache qui ne correspond plus à l’épinglage est téléchargé de nouveau et remplacé ; sous --offline, le build échoue à la place en nommant le fichier en cache. Un épinglage s’ajoute à ce que publie le registre au lieu de le remplacer : une empreinte npm ou Maven reste vérifiée.

Seule une source téléchargée en accepte un. Épingler un chemin de votre projet est refusé : ses octets sont déjà ceux que contient votre arborescence.

Un téléchargement est borné en taille et en durée : une réponse de plus de 64 MiB échoue, tout comme un hôte qui répond puis se bloque, au lieu de figer votre configuration.

Paquets npm

L’archive tar est mise en cache une fois pour l’espace de travail, extraite dans le répertoire de build de votre projet, sous .dev/extensions/npm/<name>/<version>/contents/, et chargée depuis son point d’entrée : celui que nomme extension.json si le paquet en fournit un, sinon le main de package.json, sinon index.js. Un paquet qui porte un extension.json est un paquet au sens plein, lu exactement comme un .elx : un paquet invalide, ou qui nomme un type que cette version d’Elide ne connaît pas, fait échouer le build au lieu de se rabattre sur main.

La résolution suit trois étapes, dans l’ordre : dependencies.npm.registry si votre manifeste en définit un, puis $ELIDE_PKGST_NPM_ENDPOINT (la même variable que lit le résolveur npm d’Elide) et, à défaut des deux, le proxy npm.pkg.st d’Elide, ou le registre public registry.npmjs.org si vous passez --use-pkgst=false.

pkl
dependencies {
  npm {
    registry = "https://npm.internal.example.com"
  }
}
utf-8 · pkl · Lignes : 5

Artefacts Maven

La coordonnée est téléchargée depuis les dépôts que vous déclarez déjà (dependencies.maven.repositories, plus le proxy d’Elide et Maven Central sauf si enableDefaultRepositories est désactivé), avec les identifiants que portent ces dépôts. Les identifiants ne sont envoyés qu’au schéma, à l’hôte et au port du dépôt lui-même ; une redirection ailleurs est suivie sans eux. L’habituel [:packaging[:classifier]] se place entre l’artefact et la version, et une coordonnée sans packaging prend elx : maven:com.acme:tasks:1.4.0 télécharge tasks-1.4.0.elx, comme maven:com.acme:tasks:elx:1.4.0. Un packaging qu’Elide ne sait pas charger (maven:com.acme:tasks:zip:1.4.0) est refusé comme toute déclaration impossible à charger.

Les dépôts sont essayés dans l’ordre. Un dépôt qui répond par une erreur, par une réponse trop volumineuse ou bloquée, ou par une redirection refusée est ignoré au profit du suivant ; un .sha1 publié qui ne correspond pas à l’artefact fait échouer le build.

Un seul artefact est téléchargé, et aucun graphe de dépendances n’est résolu, exactement comme pour une archive déclarée par chemin. Une extension qui a besoin de bibliothèques les embarque dans son paquet.

Configurer une extension

Une extension qui accepte des réglages est déclarée comme un bloc plutôt que comme un simple chemin, et les porte avec elle. Elide ne les interprète pas :

pkl
extensions {
  "tools/shellcheck.ts"
  new {
    from = "tools/protoc.ts"
    config {
      version = "25.1"
      plugins = new Listing { "grpc" }
    }
  }
}
utf-8 · pkl · Lignes : 10

Ce bloc parvient à cette extension, et à elle seule, sous le nom config, pendant qu’elle déclare des tâches et pendant que celles-ci s’exécutent :

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

Une extension déclarée par un simple chemin lit un config vide : vérifier qu’un réglage vaut undefined suffit donc pour appliquer une valeur par défaut.

Une extension qui veut des réglages typés et documentés fournit son propre module Pkl, que votre projet copie, importe et remplit. Un manifeste importe des fichiers locaux, la bibliothèque standard Pkl et le schéma propre à Elide, jamais un package:// ni une autre URI réseau : le module vit donc dans votre projet :

pkl
import "tools/Protoc.pkl"

extensions {
  new {
    from = "tools/protoc.ts"
    config = new Protoc.Settings { version = "25.1" }
  }
}
utf-8 · pkl · Lignes : 8

Les entrées sont distinguées par chemin, quelle que soit leur écriture : la même extension ne peut donc pas être déclarée (ni configurée) deux fois.

Écrivez une liste dans un bloc sous forme de Listing (ou List("grpc")). Des éléments en ligne, comme plugins { "grpc" }, parviendraient à votre extension indexés par position plutôt que sous forme de liste : le manifeste les refuse donc et vous indique l’écriture qui fonctionne.

Les réglages sont des données. Une valeur peut être une chaîne, un booléen, un nombre, une Duration, une DataSize (qui parvient à votre extension sous forme de nombre d’octets) ou une Regex, ou encore un bloc, Listing, List, Set, Map ou Pair de celles-ci. Un Bytes, une classe, un alias de type ou une fonction est une erreur de manifeste qui en indique l’emplacement, par exemple config.flags.blob.

Options des tâches

OptionSignification
descriptionAffichée par elide build --inspect.
dependsOnNoms des tâches qui doivent se terminer avant ; un seul nom peut être donné sans tableau.
beforeTâches que celle-ci doit précéder : un nom de tâche, ou deps/compile/test pour toutes les tâches de ce groupe.
groupdeps, compile ou test pour rejoindre un groupe intégré ; tout autre nom en crée un à vous.
defaultSi un simple elide build exécute cette tâche. Par défaut true.
inputsChemins ou globs relatifs au projet dont le contenu décide si la tâche se réexécute.
outputsChemins ou globs relatifs au projet que la tâche écrit.
cacheSi le cache de build peut archiver les sorties de cette tâche. Par défaut true.
optionsOptions de ligne de commande que cette tâche accepte (voir ci-dessous).

inputs et outputs forment un seul contrat : déclarez les deux et Elide saute la tâche quand rien n’a changé (voir Tâches de build) ; déclarez-en un seul ou aucun et elle s’exécute à chaque build. Votre extension compte aussi comme entrée (le fichier lui-même et chaque module qu’il importe par chemin relatif) : modifier le point d’entrée ou l’un de ses utilitaires réexécute donc les tâches qu’il déclare.

Une tâche qui a déclaré les deux est aussi mise en cache comme celles d’Elide : ses sorties sont archivées quand elle réussit et restaurées dans tout build ultérieur dont les entrées ont la même empreinte, y compris dans une copie de travail neuve. Définissez cache: false pour garder la vérification de mise à jour et sauter l’archive ; c’est le bon choix quand vos sorties dépendent de quelque chose que vos inputs ne nomment pas.

L’empreinte d’une déclaration est calculée à partir du mode et du contenu de chaque fichier qu’elle nomme, et votre action lit les mêmes fichiers et les mêmes empreintes via ctx.inputs et ctx.outputs ; voir Les fichiers que nomme une déclaration.

Options acceptées par votre tâche

Une tâche qui lit une option doit le déclarer. Les options déclarées sont affichées par elide build --inspect et par elide build <task> --help, et vérifiées avant toute exécution : une faute de frappe est donc signalée au lieu d’être ignorée en silence :

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
  },
);
utf-8 · ts · Lignes : 15
ChampSignification
nameL’option, sans tirets et en kebab-case : "dry-run" donne --dry-run.
descriptionAffichée par --inspect et --help.
arity"none" est une option sans valeur, "required" exige --flag=value, "optional" accepte les deux. Par défaut "none".
repeatableSi l’option peut être passée plusieurs fois. Par défaut false.
negatableSi --no-<name> est accepté, lu comme "false". Par défaut false.
defaultCe qu’une lecture renvoie quand l’option n’a pas été passée.
keyedSi la valeur passée à l’option décide de ce que produit la tâche. Par défaut true.

--inspect et --help affichent la déclaration telle qu’il faut la taper : les trois ci-dessus se lisent donc --channel=<value>, --tag=<value> (repeatable) et --sign (or --no-sign) [default: true].

Une option que rien ne déclare fait échouer le build, en indiquant le nom déclaré le plus proche s’il y en a un ; de même pour une valeur donnée à une option sans valeur, une option obligatoire laissée sans valeur et la répétition d’une option qui n’accepte qu’une valeur. Les options propres à Elide (--verbose, --project, --offline et les autres) sont toujours acceptées.

ctx.option, ctx.flag et ctx.values lisent à travers la déclaration, ce qui rend --no-sign et une valeur par défaut lisibles : ni l’un ni l’autre n’est dit par la ligne de commande à elle seule. Un nom que votre tâche n’a pas déclaré se lit toujours depuis la ligne de commande telle qu’elle a été analysée.

Une option déclarée fait partie de ce qui décide si votre tâche se réexécute : les valeurs vers lesquelles elle s’est résolue sont enregistrées à côté de vos inputs, de sorte qu’un build sous --channel=stable n’est jamais déclaré à jour (ni ne reçoit ce que --channel=canary a mis en cache) par rapport à un build exécuté sous l’autre. Définissez keyed: false sur une option qui change le niveau de bavardage de la tâche plutôt que ce qu’elle écrit ; sinon, chaque build qui l’écrit différemment rate le cache pour rien. Une option que votre action lit sans la déclarer ne décide de rien.

S’exécuter avant une autre tâche

dependsOn nomme ce qui doit s’exécuter d’abord. before nomme ce qui doit attendre, y compris des tâches que vous n’avez pas déclarées, ce dont a besoin une tâche de génération de code :

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

compile, deps et test représentent toutes les tâches de ce groupe, ce que doit signifier « avant la compilation » : les vraies clés sont par langage et par source set (compile-kotlin-main, compile-java-test). Tout autre nom est celui d’une seule tâche, et un nom que rien n’a enregistré fait échouer le build au lieu de n’exécuter silencieusement rien. Dans un espace de travail, une extension déclarée par un membre ne sélectionne que les tâches de ce membre, tandis qu’une extension déclarée par le manifeste racine sélectionne le groupe dans tous les membres.

Une telle arête entraîne aussi votre tâche dans tout build qui exécute la tâche qu’elle précède : elide build compile ci-dessus exécute d’abord generate-sources, même si celle-ci a renoncé à default.

Builds incrémentaux

Une tâche en cours d’exécution apprend ce que le build a trouvé périmé et peut conserver des valeurs pour sa prochaine exécution :

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);
});
utf-8 · ts · Lignes : 8

ctx.dirty ne nomme que les déclarations qui ont changé ; lors d’une première exécution, tout ce que vous avez déclaré est listé. ctx.state contient des chaînes, des nombres, des booléens, null, des tableaux et des objets simples, indexés par nom, et get renvoie null pour une clé sous laquelle rien n’a été stocké. Une fonction, un symbole et undefined n’ont pas de forme stockée, et une valeur qui se contient elle-même est refusée au lieu d’être parcourue. Une exécution qui échoue jette entièrement l’état de la tâche, y compris ce que les exécutions précédentes avaient enregistré.

Ce dont une tâche se souvient est un mémo, pas une entrée : cela ne décide jamais si la tâche se réexécute et ne change jamais sa clé de cache.

Les fichiers que nomme une déclaration

ctx.inputs et ctx.outputs sont les déclarations elles-mêmes, résolues et dotées de leur empreinte : le même parcours et la même empreinte que le build a calculés pour décider si votre tâche était à jour :

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()}`);
});
utf-8 · ts · Lignes : 14

files() liste chaque fichier ordinaire que nomment les déclarations, relatif au projet et séparé par /, trié et sans doublons. Chaque entrée porte path, absolute, size, modified et stamp, un SHA-256 du mode et du contenu de ce fichier. stamp() est l’empreinte de l’ensemble.

Les deux acceptent leurs propres motifs, qui n’ont pas besoin d’être ceux que vous avez déclarés : ctx.inputs.files("proto/api/**/*.proto") restreint une liste, et ctx.inputs.stamp("proto") est exactement l’empreinte que le build a enregistrée pour cette déclaration. Les deux se résolvent au moment de l’appel : lire ctx.outputs après avoir écrit vos sorties décrit donc ce que vous venez de produire.

Les empreintes dépendent du contenu : une modification qui laisse inchangées la taille et la date de modification d’un fichier les change quand même, et réexécute votre tâche. ctx.dirty nomme une déclaration dans son ensemble ; une empreinte par fichier conservée dans ctx.state, comme ci-dessus, permet à une tâche de savoir lesquels de ses fichiers ont bougé.

Déclarations que le build doit calculer

Une partie de ce qui décide si une tâche est à jour ne peut pas s’écrire dans la déclaration : une arborescence générée, un classpath qu’une autre tâche a résolu, la version de l’outil que vous vous apprêtez à lancer. Donnez à la tâche un hook prepare et déclarez-le là :

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`);
  },
);
utf-8 · ts · Lignes : 14

Ce qu’ajoute un hook est une déclaration comme une autre : un motif reçoit une empreinte, apparaît dans ctx.dirty.inputs et entre dans la clé de cache de la tâche exactement comme un motif déclaré. ctx.inputs.value(name, value) déclare une entrée qui n’est pas un fichier (tout ce que le build peut calculer sans pouvoir le consulter par stat), et un changement de celle-ci réexécute la tâche et change la clé de son archive ; ctx.dirty.values nomme celle qui a changé. Une tâche dont la déclaration est complétée ainsi est suivie, mise en cache et sautée comme à jour comme n’importe quelle autre.

Le hook s’exécute à chaque build auquel votre tâche participe, y compris ceux où elle n’a finalement rien à faire : limitez-le donc à dériver des déclarations. Il n’a ni exec, ni tools, ni dirty (c’est la réponse qu’il aide à calculer), et ctx.state y est en lecture seule. Renvoyer false fait échouer la tâche avant qu’elle s’exécute.

Votre action peut ajouter des sorties qu’elle ne connaît qu’en les écrivant, avec le même ctx.outputs.add(...) ; elles reçoivent une empreinte et sont archivées avec les autres à son retour. Elle ne peut pas ajouter d’entrées : à ce stade le build a déjà décidé de l’exécuter, donc un ctx.inputs.add tardif est une erreur plutôt qu’une déclaration qui ne fait silencieusement rien.

Transmettre une valeur à une autre tâche

Le module de votre extension est évalué une fois par build et partagé par toutes les tâches qu’il déclare : une variable de module qu’une tâche affecte est donc souvent encore là pour la suivante, mais rien ne le garantit, et une nouvelle évaluation repart de zéro. Traitez la portée du module comme un cache, jamais comme un canal. Ce qu’une tâche garde dans ctx.state est la façon prise en charge de transmettre une valeur :

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"]);
});
utf-8 · ts · Lignes : 9

ctx.state.of(name) renvoie une vue de l’état de cette tâche (get, has et keys), et null quand elle n’a rien publié. Une tâche que le build a sautée parce qu’elle était à jour, ou restaurée depuis le cache, répond avec ce qu’elle a gardé lors de sa dernière exécution : être à jour est une affirmation sur ce qu’elle a produit, et ce qu’elle a publié en fait partie. null signifie qu’il n’y avait rien à lire (le nom n’est pas une tâche déclarée par une extension, elle n’était pas dans ce build, elle a échoué ou elle ne s’est pas encore exécutée), tandis qu’une vue sans clés est une tâche qui n’a rien gardé. Faites dépendre votre tâche de celle que vous lisez ; sans arête, « ne s’est pas encore exécutée » est une situation de concurrence.

Une valeur passe avec la structure qu’elle avait, pas sous forme de texte, et les mêmes règles que pour ctx.state s’appliquent à ce qui peut passer. Lire votre propre nom renvoie ce que vous détenez à cet instant.

Ce que reçoit votre tâche

L’objet passé à votre action porte les mêmes informations que celles que lit le build lui-même :

  • project : nom, version, description, répertoire racine et l’espace de travail auquel il appartient.
  • manifest : votre elide.pkl résolu, sous forme de données JavaScript ordinaires. C’est exactement ce qu’écrit elide project export --format=json : manifest.dependencies.maven.packages.map(...) fonctionne donc comme prévu.
  • config : les réglages que votre manifeste a déclarés pour cette extension (voir ci-dessus), vide s’il n’en a déclaré aucun.
  • layout : projectDir, buildDir, artifacts, staging et consorts, sous forme de chemins absolus.
  • option(name) / flag(name) / values(name) : les options avec lesquelles cette invocation a été lancée, lues à travers votre déclaration (voir ci-dessus) ; options est l’analyse brute, pour tout ce que vous n’avez pas déclaré.
  • task : votre propre déclaration, telle que le build l’a enregistrée, y compris les options qu’elle déclare.
  • dirty / state : ce qui a changé depuis la dernière exécution de votre tâche, ce qu’elle a gardé et ce que d’autres tâches de ce build ont enregistré (voir ci-dessus).

Signaler le résultat

Dans votre actionCe que le build enregistre
return, ou renvoyer truesuccès
ctx.fail(reason)échec, avec ce motif
renvoyer falseéchec, sans motif
ctx.skip(reason)sautée, avec ce motif
throw, ou rejectéchec, avec son message
ctx.exit(code, reason)échec avec sortie sur code

ctx.log, ctx.warn et ctx.error écrivent des lignes attribuées à votre tâche. Ce sont des messages de build : ils vont donc sur la sortie d’erreur standard avec tous les autres messages de build ; la sortie standard ne contient que ce qu’écrit un programme que vous avez lancé.

Sortir avec un statut précis

ctx.exit(code, …message) fait échouer la tâche et demande au build de sortir avec code. 0 signifie au contraire un succès, ce qui en fait toute la décision de réussite ou d’échec pour une tâche qui encapsule un programme :

ts
ctx.exit(ctx.exec("shellcheck", ["scripts/build.sh"]).exitCode);
utf-8 · ts · Lignes : 1

Utilisez-le quand un statut porte un sens qu’un booléen détruirait. Un cliquet de correction qui sort avec 2 parce que l’analyseur a planté et avec 1 parce qu’il a trouvé une nouvelle violation n’est utile que si l’appelant peut encore les distinguer ; de même pour un diagnostic qui sort avec 2 pour « non pris en charge sur cet hôte » et avec 1 pour « quelque chose a été trouvé ».

Le code n’atteint le processus que si votre tâche est celle qui a arrêté le build : si une autre tâche échoue d’abord, elle l’emporte. Un code hors de 0-255 est réduit comme le ferait un shell, et ne revient jamais à zéro.

Lancer des outils externes

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
utf-8 · ts · Lignes : 11

exec bloque jusqu’à la fin du programme et diffuse sa sortie dans l’affichage du build au fil de l’eau, attribuée à votre tâche. Aucun shell n’intervient : les arguments parviennent au programme exactement comme vous les avez écrits, donc rien dans un nom de fichier ou une option ne peut être réinterprété comme syntaxe shell. Un nom de commande seul est recherché dans le PATH avec lequel le processus enfant s’exécutera (celui que définit votre env, s’il en définit un), et un nom contenant un séparateur est résolu par rapport à cwd. Annuler le build tue le programme et tout ce qu’il a lancé. timeout borne le programme que vous avez nommé ; une fois celui-ci terminé, la sortie qui arrive encore est lue pendant une seconde de plus puis abandonnée : un processus d’arrière-plan qui garde sa sortie ouverte ne retient donc pas votre tâche. Ce processus n’est ni attendu ni tué.

Tout ce dont dispose déjà le JavaScript invité est aussi disponible (node:fs, node:child_process, fetch) : utilisez exec quand vous voulez que la sortie soit attribuée à votre tâche, et node:child_process quand vous avez besoin de quelque chose qu’il ne couvre pas. Une règle l’accompagne : un processus enfant à qui vous donnez le terminal du build (stdio: "inherit") écrit là où l’affichage de progression dessine ; cédez-lui donc le terminal avec ctx.foreground. Un processus enfant dont vous redirigez et affichez vous-même la sortie n’a besoin de rien : la sortie de votre console passe par l’affichage comme toute autre.

Céder le terminal à un programme

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

L’affichage de progression cesse de dessiner exactement pendant l’exécution de la fonction, et revient quelle que soit la façon dont elle se termine : la sortie du programme et les lignes de l’affichage ne s’effacent donc jamais mutuellement. Une fonction async garde le terminal jusqu’à ce que sa promesse soit réglée, et await ctx.foreground(...) renvoie ce que la fonction a renvoyé et relance ce qu’elle a levé.

Encapsulez la passation, pas toute la tâche : tout ce que fait votre tâche avant de lancer le processus (résoudre des chemins, lire des fichiers, appeler ctx.tools) doit continuer à afficher la progression. N’y recourez que lorsque quelque chose écrit sur les descripteurs propres au build, c’est-à-dire un processus enfant avec stdio hérité ou tout ce qui est interactif, comme un REPL ou un débogueur. La sortie écrite avec ctx.log, ctx.exec, ctx.tools ou la console de votre tâche est déjà linéarisée par rapport à l’affichage.

Utiliser la chaîne d’outils intégrée

Elide embarque déjà un compilateur Kotlin, javac, jar, Native Image, ktfmt et google-java-format. Une tâche qui a besoin de l’un d’eux n’a pas à lancer un autre Elide pour y accéder :

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" },
});
utf-8 · ts · Lignes : 24

Chaque appel bloque jusqu’au retour de l’outil et renvoie { ok, exitCode, tool, diagnostics }. Les diagnostics du compilateur et du formateur s’affichent dans la sortie du build exactement comme pour une compilation intégrée, et reviennent dans diagnostics sous la forme { severity, message, file, line, column } ; jar et nativeImage produisent du texte, diffusé dans le journal du build et attribué à votre tâche (passez quiet: true pour le masquer), et laissent diagnostics vide. Les chemins sont relatifs au projet sauf s’ils sont absolus. Vous pouvez écrire des sorties n’importe où, mais seules celles sous le répertoire du projet peuvent être mises en cache et restaurées : gardez-les donc là quand la tâche les déclare.

  • kotlinc({ sources, out, classpath?, friendPaths?, args?, incremental?, plugins? }) : le compilateur Kotlin intégré. Les plugins du compilateur que votre manifeste déclare ou active (kotlin.features, kotlin.plugins, mavenDependencies().kotlinPlugins) sont appliqués comme pour la compilation intégrée, et les runtimes que fournissent ces fonctionnalités (celui de Metro, par exemple) rejoignent le classpath avec eux : un plugin sans son runtime fait échouer le compilateur au lieu de ne rien faire. plugins: false lance le compilateur nu, ce qu’il faut pour des sources ne contenant que des annotations. Aucun -jvm-target n’est ajouté sauf si vous en passez un dans args.
  • javac({ sources, out, classpath?, processorPath?, args? }) : le compilateur Java intégré ; un processorPath active le traitement des annotations.
  • jar({ out, entries, exclude?, date?, manifest?, mainClass?, quiet? }) : chaque entrée est un répertoire (son contenu), un fichier ou une autre archive dont les entrées sont reprises (archive: true), placée à la racine de l’archive ou sous into. Manifest-Version et Created-By sont toujours écrits ; mainClass devient Main-Class.
  • nativeImage({ args, cwd?, quiet? }) : le pilote Native Image intégré ; les chemins relatifs de args sont ancrés sur cwd, qui vaut par défaut la racine du projet. Le builder s’exécute comme JVM enfant épinglée au JDK propre à Elide.
  • format({ tool, files, check?, args? }) : les formateurs intégrés : tool vaut "ktfmt" pour Kotlin ou "javaformat" pour google-java-format. files sont des fichiers, des répertoires ou des globs, restreints à ce que le formateur accepte (.kt/.kts ou .java) ; ne rien trouver réussit sans rien faire. Les fichiers sont réécrits sur place ou, avec check: true, seulement signalés, et l’appel échoue si un fichier devait changer. Dans les deux cas, changed liste les chemins absolus des fichiers qui n’étaient pas formatés, et chacun arrive sous forme de diagnostic : un avertissement sous check, une information pour une réécriture, une erreur pour un fichier qui n’a pas pu être lu ou analysé. args transmet d’autres options au formateur, comme ["--google-style"].
  • classpath({ sourceSet?, usage? }) : le classpath de dépendances résolu, sous forme de chemins absolus dans l’ordre. usage vaut "compile" (par défaut ; ce que voient les tâches de compilation), "runtime", "test" (le classpath d’exécution du source set de test), "processor" ou "modules". Il renvoie ce que ce build a résolu : une tâche qui le lit devrait donc déclarer dependsOn: "maven-dependencies".

ctx.tools.version est la version de ces formes d’options et de résultats : 1 aujourd’hui. Elle ne change qu’en cas de modification incompatible ; une nouvelle option ou un nouveau membre de résultat facultatif ne la change pas. Une extension qui doit fonctionner avec plusieurs versions d’Elide se branche sur sa valeur.

Les compilateurs ne s’exécutent dans le processus qu’à travers la chaîne d’outils intégrée. Un manifeste qui sélectionne une kotlin.toolchain personnalisée ou gérée, ou le jvm.java.compiler.mode externe, reçoit de ctx.tools.kotlinc / ctx.tools.javac une erreur qui nomme la tâche et l’outil ; supprimez la sélection, ou lancez cette chaîne d’outils comme programme avec ctx.exec. Il en va de même pour une distribution construite sans la chaîne d’outils JVM.

Réempaqueter une archive

jar lit une archive existante comme source : alléger une dépendance et la tamponner de façon reproductible tient donc en un seul appel dans le processus, au lieu d’une chaîne 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",
});
utf-8 · ts · Lignes : 6

archive: true n’est jamais déduit de l’extension du fichier : sans lui, un .jar nommé par from est ajouté comme un seul fichier. Le META-INF/MANIFEST.MF propre à l’archive source est abandonné (le résultat porte le manifeste que décrivent manifest et mainClass, jamais celui de la source), et into change la racine de tout le reste. Un membre dont le chemin, après into, se retrouverait hors de l’archive (../x, ou un /x absolu) fait échouer la tâche au lieu d’être écrit, tout comme un into qui en sort.

Les globs exclude sont comparés au chemin qu’aurait une entrée dans l’archive, après le changement de racine par into. Ce sont des chemins, pas des noms de paquets : une arborescence de paquets s’écrit "kotlin/**" et un type de fichier "META-INF/*.SF". Un glob dans les options s’applique à toutes les entrées quelle que soit leur source ; un glob sur une entrée ne s’applique qu’à cette entrée, en plus des précédents.

date tamponne chaque entrée, manifeste compris, à cet instant, et l’outil écrit les fichiers d’une racine dans l’ordre trié : réempaqueter une entrée inchangée donne donc un résultat identique à l’octet près, ce dont a besoin un tampon AOT ou de distribution. Sans lui, les dates des entrées ne sont pas reproductibles : une entrée transmise telle quelle garde les dates de modification de ses fichiers, tandis qu’un membre d’archive, une entrée avec into et une entrée touchée par une exclusion ou une collision portent l’heure de l’appel.

Les entrées sont empaquetées dans l’ordre où vous les listez, et deux d’entre elles peuvent revendiquer le même chemin dans l’archive : la dernière l’emporte, et le build avertit en nommant le chemin et la source dont il a été pris. Rien n’est fusionné : un artefact Jar déclaré dans le manifeste réunit les fichiers META-INF/services de ses racines, ctx.tools.jar non ; une tâche qui a besoin de cette réunion écrit donc elle-même le fichier fusionné et le liste en dernier.

Cette page vous a-t-elle aidé ?