Saltar al contenido

Extensiones de compilación

Declare sus propias tareas de compilación en JavaScript o TypeScript: escriba, empaquete, descargue y configure extensiones y ejecute herramientas externas.

Leer sin conexiónelide help --lang es tooling-build-extensions

En esta página

Elide integra un compilador, un resolvedor de Maven, un ensamblador de contenedores y un ejecutor de pruebas. No integra protoc, shellcheck ni lo que su proyecto necesite a continuación. Una extensión de compilación es la forma de enseñarle a su compilación una herramienta más: un módulo JavaScript o TypeScript que Elide evalúa mientras configura la compilación.

Una extensión declara tareas. Cada una se convierte en una tarea real del grafo: puede nombrarla como destino de compilación, hacer que otra tarea dependa de ella, ponerla en un grupo y verla en elide build --inspect.

Declarar una extensión

Indíquela en su manifiesto, relativa a la raíz del proyecto:

pkl
amends "elide:project.pkl"

name = "demo"

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

Las extensiones se declaran, nunca se descubren, así que ejecutar una siempre es algo que su proyecto pidió. Una ruta que no existe hace fallar la compilación en lugar de no registrar nada en silencio.

Una ruta se resuelve contra la raíz del proyecto y debe quedarse dentro de su árbol de fuentes: la raíz del proyecto, o la raíz del espacio de trabajo cuando el proyecto es miembro de uno, para que los miembros puedan compartir un archivo situado por encima de sus propias raíces (../tools/protoc.ts). Una ruta absoluta fuera del árbol, una ruta que sale de él y un enlace simbólico que apunta fuera de él se rechazan, y cada uno se notifica como cualquier otra declaración incorrecta.

Una declaración nombra una de dos cosas, y Elide decide cuál solo por la extensión del archivo: un módulo ES (.ts, .mts, .js, .mjs, .jsx, .tsx) o un archivo de paquete .elx (consulte Empaquetar una extensión). .cjs no se acepta: el contrato es una exportación predeterminada, que un script CommonJS no puede declarar, y un .js siempre se lee como módulo diga lo que diga el package.json más cercano. Cualquier otra cosa hace fallar la compilación en lugar de intentar adivinarla.

Una entrada no tiene por qué ser una ruta: puede nombrar un paquete que se descarga de una URL, de npm o de Maven. Consulte Descargar una extensión.

Escribir una

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 · Líneas: 18

Después:

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

Los tipos se publican en @elide-dev/types como ElideBuild, ElideBuildTaskContext y ElideBuildTaskOptions.

La exportación predeterminada de su módulo se ejecuta mientras se configura la compilación y puede ser async. La acción de cada tarea se ejecuta cuando lo hace la tarea y también puede ser async; la compilación espera en ambos casos.

Empaquetar una extensión

Una extensión que ya no cabe en un solo archivo se distribuye como archivo comprimido: un zip con un manifiesto llamado extension.json en su raíz y con nombre .elx. El nombre no es decorativo: la extensión de archivo de la declaración es lo que indica a Elide que se trata de un paquete, y .elx es la única que lo hace, así que tools/protoc.elx se carga y los mismos bytes con el nombre tools/protoc.zip no.

protoc-tasks/
├── extension.json
├── tasks.ts
└── lib/
    └── flags.ts
utf-8 · texto · Líneas: 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 · Líneas: 11

El manifiesto del archivo exige name y type. type es source (el único tipo que existe), y un paquete source también debe indicar un entrypoint, una ruta relativa a la raíz del archivo; se rechaza uno que apunte fuera del archivo. Todo lo demás son metadatos que Elide le devuelve, y las claves que no conoce se ignoran, así que un paquete puede llevar claves propias.

Compilar el paquete

Declare el paquete como artefacto y elide build lo produce:

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 · Líneas: 17

Eso registra una tarea llamada package-extension-tasks, que ejecuta un simple elide build y que escribe .dev/artifacts/tasks.elx. El extension.json anterior se escribe automáticamente: name, version y description toman por defecto los del proyecto, y type siempre es source.

entrypoint es el módulo cuya exportación predeterminada declara sus tareas. srcs son los módulos que importa, como rutas o patrones glob relativos a la raíz del proyecto; el punto de entrada siempre se empaqueta. Los archivos conservan las rutas que tienen en su proyecto, de modo que las importaciones relativas se resuelven dentro del archivo exactamente igual que en su copia de trabajo. Cada archivo empaquetado debe estar dentro del proyecto una vez resueltos sus enlaces: un enlace simbólico que apunta fuera del proyecto hace fallar la compilación en lugar de distribuir aquello a lo que apunta.

Por supuesto, también puede crear el zip usted mismo, desde el directorio que contiene el manifiesto, para que extension.json quede en la raíz:

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

Un paquete de fuentes

"type": "source" significa que el archivo lleva su código fuente, y la extensión de archivo del punto de entrada decide cómo se carga, igual que con un archivo que declara directamente. Un punto de entrada .ts, .tsx, .mts, .js, .jsx o .mjs se evalúa como módulo invitado y, como Elide descomprime el archivo antes de evaluarlo, las importaciones relativas dentro del paquete (import { flags } from "./lib/flags.ts") se resuelven.

Nada se empaqueta ni se compila por el camino: un paquete distribuye las fuentes que usted escribió, y la extensión que escribe es la misma tanto si viaja como un archivo como si lo hace como archivo comprimido: la misma exportación predeterminada, el mismo task(...), el mismo ctx.

Declarar un paquete

Como una extensión local (una ruta, con ajustes si los admite) o como algo que se descarga:

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

Elide descomprime el archivo en su directorio de compilación, en .dev/extensions/packages/<archive>-<hash>/contents/, y vuelve a descomprimirlo cada vez que el archivo cambia. Una entrada que se escribiría fuera de ese directorio se rechaza en lugar de descomprimirse.

Qué muestra --inspect

elide build --inspect imprime una sección Extensions: que cubre todo lo que declaró su manifiesto. Para cada una muestra la declaración que escribió, el nombre y la versión del paquete, su descripción, qué tipo de extensión es (JavaScript/TypeScript module o packaged extension), su licencia, su repositorio y los nombres de las tareas que declaró.

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 · texto · Líneas: 5

La distribución del archivo corre de su cuenta: adjúntelo a una versión, sírvalo desde una URL o publíquelo como paquete npm con lo que ya utilice. Viaje como viaje, un proyecto lo consume de la misma forma; consulte Descargar una extensión.

Descargar una extensión

Una extensión no tiene por qué estar en su proyecto. Una declaración que empieza por uno de tres esquemas reservados nombra algo que se descarga:

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 · Líneas: 9

Solo url:, npm: y maven: están reservados. Todo lo demás es una ruta, así que C:\tools\ext.ts sigue significando lo que dice, y un esquema que Elide añada más adelante no puede cambiar cómo se lee un manifiesto que ya compila.

Pasada la descarga, nada cambia. Un .elx descargado se carga como paquete y un .ts descargado como módulo invitado: decide aquello en lo que se resolvió, no cómo llegó. Una url: debe ser una URL https absoluta; el http sin cifrar se rechaza salvo hacia un host de loopback, que existe para que pueda probar contra algo local. Las redirecciones se siguen (hasta cinco saltos) y cada salto debe cumplir la misma regla, así que una redirección no puede llevar una descarga de loopback a otra máquina sin cifrar.

Las versiones están fijadas

Una versión remota nombra exactamente una publicación. El esquema del manifiesto rechaza npm:tasks y maven:com.acme:tasks por no nombrar ninguna, y npm:tasks@^1.2, npm:tasks@latest y maven:com.acme:tasks:1.2.3-SNAPSHOT por nombrar más de una.

Es cosa de la caché. Una extensión descargada se guarda bajo lo que usted declaró, así que una versión cambiante o bien costaría una consulta al registro en cada compilación (lo que haría imposible --offline), o bien se congelaría en silencio en lo que viera su primera compilación.

Dónde se almacena en caché

Los artefactos descargados se guardan en .dev/dependencies/extensions/remote/, junto a su repositorio Maven resuelto y compartidos por todo un espacio de trabajo de la misma forma. Como la declaración está fijada, lo que hay en caché nunca está obsoleto: solo la primera compilación accede a la red, y elide build --offline funciona a partir de entonces. Una declaración que no se ha descargado y no puede descargarse lo indica:

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 · texto · Líneas: 2

Se comprueba cualquier resumen criptográfico que publique el origen: dist.integrity o dist.shasum de npm, y el archivo .sha1 adjunto de Maven. Un repositorio que sirve un artefacto sin resumen es válido; uno que sirve ambos y se contradice hace fallar la compilación.

Fijar los bytes

Una versión fijada indica qué publicación se ejecuta; un integrity fijado indica qué bytes. Escríbalo como Subresource Integrity (sha256-<base64> o sha512-<base64>) junto al origen al que pertenece:

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

Una url: solo se valida por TLS y por su host, así que el valor fijado es lo que la hace reproducible. Se comprueba antes de guardar la descarga en caché y de nuevo en cada acierto de caché, y un paquete npm se ejecuta desde un árbol extraído de esos mismos bytes verificados, así que ni el servidor ni la caché compartida del espacio de trabajo pueden cambiar lo que se ejecuta a sus espaldas. Un archivo en caché que ya no coincide con el valor fijado se vuelve a descargar y se reemplaza; con --offline, la compilación falla e indica el archivo en caché. El valor fijado se suma a lo que publique el registro en lugar de reemplazarlo: el resumen de npm o de Maven se sigue comprobando.

Solo un origen descargado lo admite. Fijar una ruta dentro de su proyecto se rechaza: sus bytes ya son los que contenga su árbol.

Una descarga está limitada en tamaño y en tiempo: falla una respuesta de más de 64 MiB, y también un host que responde y luego se detiene, en lugar de bloquear su configuración.

Paquetes npm

El tarball se guarda en caché una vez para el espacio de trabajo y se extrae en el directorio de compilación de su proyecto, en .dev/extensions/npm/<name>/<version>/contents/, y se carga desde su punto de entrada: el que indique extension.json si el paquete lo incluye; si no, el main de package.json; si no, index.js. Un paquete que lleva un extension.json es un paquete en sentido pleno y se lee exactamente igual que un .elx: uno que no es válido, o que nombra un type que esta versión de Elide no conoce, hace fallar la compilación en lugar de recurrir a main.

La resolución sigue tres pasos, en orden: dependencies.npm.registry cuando su manifiesto lo define; después $ELIDE_PKGST_NPM_ENDPOINT (la misma variable que lee el resolvedor npm de Elide), y si ambos faltan, el proxy npm.pkg.st de Elide, o el registro público en registry.npmjs.org cuando pasa --use-pkgst=false.

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

Artefactos de Maven

La coordenada se descarga de los repositorios que ya declara (dependencies.maven.repositories, más el proxy de Elide y Maven Central salvo que enableDefaultRepositories esté desactivado) con las credenciales que llevan esos repositorios. Las credenciales solo se envían al esquema, host y puerto del propio repositorio; una redirección a cualquier otro lugar se sigue sin ellas. El habitual [:packaging[:classifier]] va entre el artefacto y la versión, y una coordenada sin empaquetado toma elx: maven:com.acme:tasks:1.4.0 descarga tasks-1.4.0.elx, igual que maven:com.acme:tasks:elx:1.4.0. Un empaquetado que Elide no puede cargar (maven:com.acme:tasks:zip:1.4.0) se rechaza como cualquier declaración que no se puede cargar.

Los repositorios se prueban en orden. Uno que responde con un error, con una respuesta demasiado grande o que se detiene, o con una redirección rechazada, se descarta en favor del siguiente; un .sha1 publicado que no coincide con el artefacto hace fallar la compilación.

Se descarga un artefacto y no se resuelve ningún grafo de dependencias, exactamente igual que con un archivo declarado por ruta. Una extensión que necesita bibliotecas las incluye dentro de su paquete.

Configurar una extensión

Una extensión que admite ajustes se declara como un bloque en lugar de una ruta simple, y los lleva consigo. Elide no los interpreta:

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

Ese bloque llega a esa extensión, y solo a ella, como config, tanto mientras declara tareas como mientras estas se ejecutan:

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

Una extensión declarada con una ruta simple lee un config vacío, así que basta con comprobar si un ajuste es undefined para aplicar un valor predeterminado.

Una extensión que quiere ajustes tipados y documentados distribuye un módulo Pkl propio, que su proyecto copia, importa y rellena. Un manifiesto importa archivos locales, la biblioteca estándar de Pkl y el esquema propio de Elide, nunca un package:// ni otra URI de red, así que el módulo vive en su proyecto:

pkl
import "tools/Protoc.pkl"

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

Las entradas se distinguen por ruta, se escriban como se escriban, así que la misma extensión no puede declararse (ni configurarse) dos veces.

Escriba una lista dentro de un bloque como Listing (o List("grpc")). Los elementos en línea, como plugins { "grpc" }, llegarían a su extensión indexados por posición en lugar de como lista, así que el manifiesto los rechaza y le indica la forma que funciona.

Los ajustes son datos. Un valor puede ser una cadena, un booleano, un número, un Duration, un DataSize (que llega a su extensión como número de bytes) o un Regex, o un bloque, Listing, List, Set, Map o Pair de ellos. Un Bytes, una clase, un alias de tipo o una función es un error del manifiesto que indica dónde se encuentra, como config.flags.blob.

Opciones de tarea

OpciónSignificado
descriptionLo muestra elide build --inspect.
dependsOnNombres de tareas que deben completarse antes; un solo nombre puede indicarse sin array.
beforeTareas a las que esta debe preceder: un nombre de tarea, o deps/compile/test para todas las tareas de ese grupo.
groupdeps, compile o test para unirse a un grupo integrado; cualquier otro nombre crea uno propio.
defaultSi un elide build sin argumentos ejecuta esta tarea. Por defecto, true.
inputsRutas o globs relativos al proyecto cuyo contenido decide si la tarea se vuelve a ejecutar.
outputsRutas o globs relativos al proyecto que escribe la tarea.
cacheSi la caché de compilación puede archivar las salidas de esta tarea. Por defecto, true.
optionsOpciones de línea de comandos que acepta esta tarea (vea más abajo).

inputs y outputs son un único contrato: declare ambos y Elide omite la tarea cuando nada ha cambiado (consulte Tareas de compilación); declare uno o ninguno y se ejecuta en cada compilación. Su extensión también cuenta como entrada (el propio archivo y cada módulo que importa por ruta relativa), así que editar el punto de entrada o uno de sus auxiliares vuelve a ejecutar las tareas que declara.

Una tarea que declaró ambos también se almacena en caché como las de Elide: sus salidas se archivan cuando tiene éxito y se restauran en cualquier compilación posterior cuyas entradas tengan la misma huella, también en una copia de trabajo nueva. Defina cache: false para conservar la comprobación de actualización y omitir el archivado; es lo adecuado cuando sus salidas dependen de algo que sus inputs no nombran.

La huella de una declaración se calcula a partir del modo y el contenido de cada archivo que nombra, y su acción lee los mismos archivos y las mismas huellas mediante ctx.inputs y ctx.outputs; consulte Los archivos que nombra una declaración.

Opciones que acepta su tarea

Una tarea que lee una opción debe declararlo. Las opciones declaradas las imprimen elide build --inspect y elide build <task> --help, y se comprueban antes de ejecutar nada, así que un error tipográfico se notifica en lugar de descartarse en silencio:

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 · Líneas: 15
CampoSignificado
nameLa opción, sin guiones y en kebab-case: "dry-run" es --dry-run.
descriptionLo muestran --inspect y --help.
arity"none" es una opción sin valor, "required" exige --flag=value, "optional" admite ambas formas. Por defecto, "none".
repeatableSi la opción puede pasarse más de una vez. Por defecto, false.
negatableSi se acepta --no-<name>, que se lee como "false". Por defecto, false.
defaultEl valor que se lee cuando la opción no se pasó.
keyedSi lo que se pasó a la opción decide lo que produce la tarea. Por defecto, true.

--inspect y --help imprimen la declaración tal como hay que escribirla, así que las tres anteriores se leen --channel=<value>, --tag=<value> (repeatable) y --sign (or --no-sign) [default: true].

Una opción que nada declara hace fallar la compilación, indicando el nombre declarado más parecido si lo hay; lo mismo ocurre con un valor dado a una opción sin valor, una opción obligatoria sin valor y la repetición de una opción que admite un solo valor. Las opciones propias de Elide (--verbose, --project, --offline y las demás) siempre se aceptan.

ctx.option, ctx.flag y ctx.values leen a través de la declaración, que es lo que permite leer --no-sign y un valor predeterminado: ninguno de los dos es algo que la línea de comandos diga por sí sola. Un nombre que su tarea no declaró se sigue leyendo de la línea de comandos tal como se analizó.

Una opción declarada forma parte de lo que decide si su tarea se vuelve a ejecutar: los valores a los que se resolvió se registran junto a sus inputs, así que una compilación con --channel=stable nunca se considera actualizada (ni recibe lo que --channel=canary guardó en caché) respecto a una que se ejecutó con la otra. Defina keyed: false en una opción que cambia cuánto informa la tarea y no lo que escribe; si no, cada compilación que la escriba de otra forma fallará en la caché sin motivo. Una opción que su acción lee sin declararla no decide nada.

Ejecutar antes de otra tarea

dependsOn nombra lo que debe ejecutarse antes. before nombra lo que debe esperar, incluidas tareas que usted no declaró, que es lo que necesita una tarea de generación de código:

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

compile, deps y test representan todas las tareas de ese grupo, que es lo que debe significar «antes de la compilación»: las claves reales son por lenguaje y por conjunto de fuentes (compile-kotlin-main, compile-java-test). Cualquier otro nombre es el de una sola tarea, y un nombre que nada registró hace fallar la compilación en lugar de no ejecutar nada en silencio. En un espacio de trabajo, una extensión que declara un miembro selecciona solo las tareas de ese miembro, mientras que una que declara el manifiesto raíz selecciona el grupo en todos los miembros.

Una arista así también incorpora su tarea a cualquier compilación que ejecute la tarea a la que precede: elide build compile ejecuta antes generate-sources en el ejemplo anterior, aunque esta haya renunciado a default.

Compilaciones incrementales

Una tarea en ejecución recibe lo que la compilación encontró obsoleto y puede conservar valores para su siguiente ejecución:

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 · Líneas: 8

ctx.dirty nombra solo las declaraciones que cambiaron; en una primera ejecución se enumera todo lo que declaró. ctx.state guarda cadenas, números, booleanos, null, arrays y objetos simples, indexados por nombre, y get devuelve null para una clave en la que no se guardó nada. Una función, un símbolo y undefined no tienen forma almacenada, y un valor que se contiene a sí mismo se rechaza en lugar de recorrerse. Una ejecución que falla descarta por completo el estado de la tarea, incluido lo que registraron ejecuciones anteriores.

Lo que una tarea recuerda es una nota, no una entrada: nunca decide si la tarea se vuelve a ejecutar y nunca cambia la clave de caché de la tarea.

Los archivos que nombra una declaración

ctx.inputs y ctx.outputs son las propias declaraciones, resueltas y con su huella: el mismo recorrido y el mismo resumen que calculó la compilación para decidir si su tarea estaba actualizada:

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 · Líneas: 14

files() enumera cada archivo normal que nombran las declaraciones, relativo al proyecto y separado por /, ordenado y sin duplicados. Cada entrada lleva path, absolute, size, modified y stamp, un SHA-256 del modo y el contenido de ese archivo. stamp() es el resumen de todo el conjunto.

Ambos admiten sus propios patrones, que no tienen por qué ser los que declaró: ctx.inputs.files("proto/api/**/*.proto") acota un listado, y ctx.inputs.stamp("proto") es exactamente la huella que la compilación registró para esa declaración. Ambos se resuelven al llamarlos, así que leer ctx.outputs después de escribir sus salidas describe lo que acaba de producir.

Las huellas dependen del contenido: una edición que no cambia el tamaño ni la fecha de modificación de un archivo sí las cambia, y vuelve a ejecutar su tarea. ctx.dirty nombra una declaración completa; una huella por archivo guardada en ctx.state, como en el ejemplo, es la forma en que una tarea sabe cuáles de sus archivos cambiaron.

Declaraciones que la compilación debe calcular

Parte de lo que decide si una tarea está al día no puede escribirse en la declaración: un árbol generado, un classpath que resolvió otra tarea, la versión de la herramienta que está a punto de ejecutar. Dé a la tarea un hook prepare y declárelo ahí:

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 · Líneas: 14

Lo que añade un hook es una declaración como cualquier otra: un patrón recibe su huella, aparece en ctx.dirty.inputs y entra en la clave de caché de la tarea exactamente igual que uno declarado. ctx.inputs.value(name, value) declara una entrada que no es un archivo (cualquier cosa que la compilación puede calcular pero no consultar con stat), y un cambio en ella vuelve a ejecutar la tarea y cambia la clave de su archivo; ctx.dirty.values nombra cuál cambió. Una tarea cuya declaración se completa así se rastrea, se almacena en caché y se omite como actualizada como cualquier otra.

El hook se ejecuta en cada compilación en la que participa su tarea, incluidas aquellas en las que resulta no tener nada que hacer, así que limítelo a derivar declaraciones: no tiene exec, ni tools, ni dirty (esa es la respuesta que ayuda a calcular), y ctx.state es de solo lectura ahí. Devolver false hace fallar la tarea antes de que se ejecute.

Su acción puede añadir salidas que solo conoce al escribirlas, con el mismo ctx.outputs.add(...); reciben su huella y se archivan con las demás cuando retorna. No puede añadir entradas: para entonces la compilación ya decidió si ejecutarla, así que un ctx.inputs.add tardío es un error en lugar de una declaración que no hace nada en silencio.

Pasar un valor a otra tarea

El módulo de su extensión se evalúa una vez por compilación y lo comparten todas las tareas que declara, así que una variable de módulo que asigna una tarea a menudo sigue ahí para la siguiente, pero nada lo garantiza, y una evaluación nueva empieza de cero. Trate el ámbito del módulo como una caché, nunca como un canal. Lo que una tarea guarda en ctx.state es la forma admitida de pasar un valor:

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 · Líneas: 9

ctx.state.of(name) devuelve una vista de lo que tiene esa tarea (get, has y keys) y null cuando no publicó nada. Una tarea que la compilación omitió por estar actualizada, o que restauró de la caché, responde con lo que guardó la última vez que se ejecutó: estar actualizada es una afirmación sobre lo que produjo, y lo que publicó forma parte de ello. null significa que no había nada que leer: el nombre no es una tarea declarada por una extensión, no estaba en esta compilación, falló o aún no se ha ejecutado; una vista sin claves es una tarea que no guardó nada. Haga que su tarea dependa de la que lee; sin una arista, «aún no se ha ejecutado» es una condición de carrera.

Un valor pasa con la estructura que tenía, no como texto, y a lo que puede pasar se le aplican las mismas reglas que a ctx.state. Leer su propio nombre devuelve lo que tiene en ese momento.

Lo que recibe su tarea

El objeto que se pasa a su acción lleva lo mismo que lee la propia compilación:

  • project: nombre, versión, descripción, directorio raíz y el espacio de trabajo al que pertenece.
  • manifest: su elide.pkl resuelto, como datos JavaScript normales. Es exactamente lo que escribe elide project export --format=json, así que manifest.dependencies.maven.packages.map(...) funciona como cabría esperar.
  • config: los ajustes que su manifiesto declaró para esta extensión (vea más arriba), vacío si no declaró ninguno.
  • layout: projectDir, buildDir, artifacts, staging y similares, como rutas absolutas.
  • option(name) / flag(name) / values(name): las opciones con las que se ejecutó esta invocación, leídas a través de su declaración (vea más arriba); options es el análisis sin procesar, para lo que no declaró.
  • task: su propia declaración, tal como la registró la compilación, incluidas las opciones que declara.
  • dirty / state: lo que cambió desde la última ejecución de su tarea, lo que guardó y lo que registraron otras tareas de esta compilación (vea más arriba).

Informar del resultado

En su acciónLo que registra la compilación
retornar, o retornar trueéxito
ctx.fail(reason)fallo, con ese motivo
retornar falsefallo, sin motivo
ctx.skip(reason)omitida, con ese motivo
lanzar una excepción o rechazarfallo, con su mensaje
ctx.exit(code, reason)fallo que termina con code

ctx.log, ctx.warn y ctx.error escriben líneas atribuidas a su tarea. Son mensajes de compilación, así que van a la salida de error estándar junto con todos los demás mensajes de compilación; la salida estándar solo lleva lo que escribe un programa que usted ejecutó.

Terminar con un estado concreto

ctx.exit(code, …message) hace fallar la tarea y pide a la compilación que termine con code. 0 indica éxito, y eso es lo que convierte esta llamada en toda la decisión de éxito o fallo para una tarea que envuelve un programa:

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

Úselo cuando un estado tiene un significado que un booleano destruiría. Un trinquete de corrección que termina con 2 porque el analizador falló y con 1 porque encontró una nueva infracción solo es útil si quien lo llama puede distinguirlos; lo mismo ocurre con un diagnóstico que termina con 2 para «no compatible con este host» y con 1 para «encontró algo».

El código llega al proceso solo cuando su tarea es la que detuvo la compilación; si otra tarea falla antes, gana esa. Un código fuera de 0-255 se ajusta como lo notifica una shell, y nunca se ajusta a cero.

Ejecutar herramientas externas

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 · Líneas: 11

exec se bloquea hasta que el programa termina y transmite su salida a la pantalla de compilación a medida que llega, atribuida a su tarea. No interviene ninguna shell: los argumentos llegan al programa exactamente como los escribió, así que nada en un nombre de archivo o una opción puede reinterpretarse como sintaxis de shell. Un nombre de comando simple se busca en el PATH con el que se ejecutará el proceso hijo (el que defina su env, si lo define), y un nombre que contiene un separador se resuelve contra cwd. Cancelar la compilación termina el programa y todo lo que haya iniciado. timeout limita el programa que nombró; una vez que termina, la salida que siga llegando se lee durante un segundo más y luego se descarta, así que un proceso en segundo plano que dejó con su salida abierta no retiene su tarea: a ese proceso ni se le espera ni se le termina.

También está disponible todo lo que ya tiene el JavaScript invitado (node:fs, node:child_process, fetch), así que use exec cuando quiera que la salida se atribuya a su tarea, y node:child_process cuando necesite algo que exec no cubre. Con eso viene una regla: un proceso hijo al que da la propia terminal de la compilación (stdio: "inherit") escribe donde se dibuja la pantalla de progreso, así que cédale la terminal con ctx.foreground. Un proceso hijo cuya salida redirige e imprime usted mismo no necesita nada: la salida de su console pasa por la pantalla como cualquier otra.

Ceder la terminal a un programa

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

La pantalla de progreso deja de dibujarse exactamente mientras se ejecuta la función, y vuelve termine como termine, así que la salida del programa y las filas de la pantalla nunca se borran entre sí. Una función async conserva la terminal hasta que su promesa se resuelve, y await ctx.foreground(...) devuelve lo que devolvió la función y vuelve a lanzar lo que lanzó.

Envuelva la cesión, no toda la tarea: todo lo que hace su tarea antes de lanzar el proceso (resolver rutas, leer archivos, llamar a ctx.tools) debe seguir mostrando progreso. Úselo solo cuando algo escribe en los descriptores propios de la compilación, es decir, un proceso hijo con stdio heredado o cualquier cosa interactiva, como un REPL o un depurador. La salida escrita con ctx.log, ctx.exec, ctx.tools o el console de su tarea ya está ordenada respecto a la pantalla.

Usar la cadena de herramientas integrada

Elide ya incluye un compilador de Kotlin, javac, jar, Native Image, ktfmt y google-java-format. Una tarea que necesita uno no tiene que iniciar otro Elide para usarlo:

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 · Líneas: 24

Cada llamada se bloquea hasta que la herramienta retorna y devuelve { ok, exitCode, tool, diagnostics }. Los diagnósticos del compilador y del formateador se muestran en la salida de la compilación exactamente como los muestra una compilación integrada, y se devuelven en diagnostics como { severity, message, file, line, column }; jar y nativeImage producen texto, que se transmite al registro de la compilación atribuido a su tarea (pase quiet: true para ocultarlo), y dejan diagnostics vacío. Las rutas son relativas al proyecto salvo que sean absolutas. Puede escribir salidas en cualquier lugar, pero solo las que están dentro del directorio del proyecto pueden almacenarse en caché y restaurarse, así que manténgalas ahí cuando la tarea las declare.

  • kotlinc({ sources, out, classpath?, friendPaths?, args?, incremental?, plugins? }): el compilador de Kotlin integrado. Los plugins del compilador que su manifiesto declara o activa (kotlin.features, kotlin.plugins, mavenDependencies().kotlinPlugins) se aplican igual que en la compilación integrada, y los runtimes que aportan esas funciones (el de Metro, por ejemplo) se añaden al classpath con ellos: un plugin sin su runtime hace fallar al compilador en lugar de no hacer nada. plugins: false ejecuta el compilador sin plugins, que es lo que necesitan las fuentes con solo anotaciones. No se añade ningún -jvm-target salvo que lo pase en args.
  • javac({ sources, out, classpath?, processorPath?, args? }): el compilador de Java integrado; un processorPath activa el procesamiento de anotaciones.
  • jar({ out, entries, exclude?, date?, manifest?, mainClass?, quiet? }): cada entrada es un directorio (su contenido), un archivo u otro archivo comprimido cuyas entradas se toman (archive: true), y se coloca en la raíz del archivo o bajo into. Manifest-Version y Created-By siempre se escriben; mainClass se convierte en Main-Class.
  • nativeImage({ args, cwd?, quiet? }): el controlador de Native Image integrado; las rutas relativas de args se anclan en cwd, que por defecto es la raíz del proyecto. El builder se ejecuta como una JVM hija fijada al propio JDK de Elide.
  • format({ tool, files, check?, args? }): los formateadores integrados: tool es "ktfmt" para Kotlin o "javaformat" para google-java-format. files son archivos, directorios o globs, limitados a lo que acepta el formateador (.kt/.kts o .java); si no coincide nada, la llamada tiene éxito y no hace nada. Los archivos se reescriben in situ o, con check: true, solo se notifican, y la llamada falla si algún archivo cambiaría. En ambos casos changed enumera las rutas absolutas de los archivos que no estaban formateados, y cada uno llega como diagnóstico: una advertencia con check, un aviso informativo en una reescritura y un error para un archivo que no pudo leerse ni analizarse. args pasa más opciones al formateador, como ["--google-style"].
  • classpath({ sourceSet?, usage? }): el classpath de dependencias resuelto como rutas absolutas en orden. usage es "compile" (predeterminado; lo que ven las tareas de compilación), "runtime", "test" (el classpath de ejecución del conjunto de pruebas), "processor" o "modules". Devuelve lo que esta compilación ha resuelto, así que una tarea que lo lee debería usar dependsOn: "maven-dependencies".

ctx.tools.version es la versión de estas formas de opciones y resultados: hoy, 1. Solo cambia con un cambio incompatible; una nueva opción o un nuevo miembro de resultado opcional no la cambia. Una extensión que debe funcionar con más de una versión de Elide se ramifica según su valor.

Los compiladores se ejecutan en el mismo proceso solo a través de la cadena de herramientas integrada. Un manifiesto que selecciona un kotlin.toolchain personalizado o gestionado, o el jvm.java.compiler.mode externo, recibe un error de ctx.tools.kotlinc / ctx.tools.javac que indica la tarea y la herramienta; elimine la selección o ejecute esa cadena de herramientas como programa con ctx.exec. Lo mismo ocurre con una distribución compilada sin la cadena de herramientas JVM.

Reempaquetar un archivo

jar lee un archivo existente como origen, así que reducir una dependencia y sellarla de forma reproducible es una sola llamada en el mismo proceso en lugar de una cadena 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 · Líneas: 6

archive: true nunca se infiere de la extensión del archivo: sin él, un .jar nombrado por from se añade como un único archivo. El META-INF/MANIFEST.MF del archivo de origen se descarta (el resultado lleva el manifiesto que describen manifest y mainClass, nunca el del origen) e into cambia la raíz de todo lo demás. Un miembro cuya ruta, tras into, quedaría fuera del archivo (../x, o un /x absoluto) hace fallar la tarea en lugar de escribirse, igual que un into que sale de él.

Los globs de exclude se comparan con la ruta que tendría una entrada dentro del archivo, después de que into haya cambiado su raíz. Son rutas, no nombres de paquete, así que un árbol de paquetes es "kotlin/**" y un tipo de archivo es "META-INF/*.SF". Un glob en las opciones se aplica a todas las entradas sea cual sea su origen; un glob en una entrada se aplica solo a esa entrada, además de los anteriores.

date sella cada entrada, incluido el manifiesto, en ese instante, y la herramienta escribe los archivos de una raíz en orden, así que reempaquetar una entrada sin cambios produce un resultado idéntico byte a byte, que es lo que necesita un sello AOT o de distribución. Sin él, las fechas de las entradas no son reproducibles: una entrada que pasa sin cambios conserva la fecha de modificación de sus archivos, mientras que un miembro de un archivo, una entrada con into y una entrada afectada por una exclusión o una colisión llevan la hora de la llamada.

Las entradas se empaquetan en el orden en que las enumera, y dos de ellas pueden reclamar la misma ruta del archivo: gana la última, y la compilación avisa indicando la ruta y el origen del que se tomó. Nada se fusiona: un artefacto Jar declarado en el manifiesto une los archivos META-INF/services de sus raíces, ctx.tools.jar no, así que una tarea que necesita esa unión escribe ella misma el archivo fusionado y lo enumera en último lugar.

¿Te resultó útil esta página?