# Extensions de build

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"
}
```

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](#packaging-an-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](#fetching-an-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);
    },
  );
};
```

Puis :

```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`](https://www.npmjs.com/package/@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
```

```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"
}
```

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." }
  }
}
```

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 :

```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" }
  }
}
```

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
```

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](#fetching-an-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" }
  }
}
```

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.
```

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=="
  }
}
```

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"
  }
}
```

### 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" }
    }
  }
}
```

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;
  });
};
```

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" }
  }
}
```

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

| Option | Signification |
| --- | --- |
| `description` | Affichée par `elide build --inspect`. |
| `dependsOn` | Noms des tâches qui doivent se terminer avant ; un seul nom peut être donné sans tableau. |
| `before` | Tâ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. |
| `group` | `deps`, `compile` ou `test` pour rejoindre un groupe intégré ; tout autre nom en crée un à vous. |
| `default` | Si un simple `elide build` exécute cette tâche. Par défaut `true`. |
| `inputs` | Chemins ou globs relatifs au projet dont le contenu décide si la tâche se réexécute. |
| `outputs` | Chemins ou globs relatifs au projet que la tâche écrit. |
| `cache` | Si le cache de build peut archiver les sorties de cette tâche. Par défaut `true`. |
| `options` | Options 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](/docs/toolchain/build-tasks)) ; 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](#the-files-a-declaration-names).

## 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
  },
);
```

| Champ | Signification |
| --- | --- |
| `name` | L'option, sans tirets et en kebab-case : `"dry-run"` donne `--dry-run`. |
| `description` | Affichée par `--inspect` et `--help`. |
| `arity` | `"none"` est une option sans valeur, `"required"` exige `--flag=value`, `"optional"` accepte les deux. Par défaut `"none"`. |
| `repeatable` | Si l'option peut être passée plusieurs fois. Par défaut `false`. |
| `negatable` | Si `--no-<name>` est accepté, lu comme `"false"`. Par défaut `false`. |
| `default` | Ce qu'une lecture renvoie quand l'option n'a pas été passée. |
| `keyed` | Si 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) => { ... },
);
```

`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);
});
```

`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()}`);
});
```

`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`);
  },
);
```

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"]);
});
```

`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 action | Ce que le build enregistre |
| --- | --- |
| return, ou renvoyer `true` | succè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);
```

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
```

`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);
    }),
);
```

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" },
});
```

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",
});
```

`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.
