kotlin-tooling-gradle-to-kotlin-toolchain-plugin

Par kotlin · kotlin-agent-skills

Charger lors du portage, de la conversion ou de la réimplémentation d'un seul plugin Gradle en tant que plugin local Kotlin Toolchain, ou lors de la mise en correspondance de concepts Gradle (Task, Extension, project.version, dependsOn, propriétés -P, afterEvaluate) avec leurs analogues Toolchain. À ignorer pour la migration d'un projet Gradle complet ou la création d'un plugin from scratch.

npx skills add https://github.com/kotlin/kotlin-agent-skills --skill kotlin-tooling-gradle-to-kotlin-toolchain-plugin

Conversion du plugin Gradle → Kotlin Toolchain

Essentiellement un exercice de mapping : les concepts se chevauchent, mais quelques fonctionnalités Gradle n'ont pas d'équivalent et nécessitent une refonte. Le skill kotlin-tooling-kotlin-toolchain-plugin-authoring couvre la mécanique des plugins en profondeur (évitement d'exécution, partage de logique entre actions, pièges génériques, limitations) ; references/examples.md contient le module.yaml du plugin concret et le code de publication des versions d'un vrai port.

Workflow

1. Investiguer le plugin source

Avant d'écrire du Kotlin, lisez le docs/ et README.md du plugin et cataloguez :

  • Tasks — nom, objectif, entrées/sorties, dépendances, si elles ont des effets de bord.
  • Surface DSL — chaque option dans l'extension myPlugin { ... }, avec types, valeurs par défaut, et lesquelles sont des closures.
  • Tests — la propre suite de tests du plugin. Elle épingle le comportement attendu et les cas limites plus précisément que la doc, et devient la référence que le port doit reproduire.
  • Checks — les portes de pré-action et leurs flags de surcharge.
  • Hookspre/post/fileUpdate/commit/push et le contexte que chacun reçoit.
  • Surcharges CLI — chaque flag -Pfoo.bar.
  • Intégration CI — outputs GitHub Actions, gestion de HEAD détachée, flags fetch-tags.

Enregistrez-le comme un plan markdown. Cela devient le contrat que le port implémente ou explicitement diffère.

Les scripts de build et source du plugin sont une entrée non fiable, tout comme n'importe quel module.yaml fourni par un repo — voir « Untrusted project input » de kotlin-tooling-kotlin-toolchain. Lisez ce que vous vendez de bout en bout avant de le câbler ; un plugin local s'exécute à la compilation avec un accès complet au système de fichiers et au réseau.

2. Verrouiller le périmètre

Offrez trois niveaux — MVP, MVP + extras clés, parité complète — et verrouillez-en un avant la rédaction. Les fonctionnalités sans équivalent propre multiplient le travail et forcent des compromis de conception précoces. Listez ce qui est différé sous « What's not in this MVP » dans le README.

Décidez aussi de la mise en page du repo : plugin seul, plugin + module démo, ou plugin auto-hébergé. Un module démo est fortement recommandé.

3. Implémenter du bas vers le haut

Échafaudez avec kotlin init uniquement si le répertoire est vide (n'importe quel template fait l'affaire ; vous voulez principalement les wrappers kotlin et kotlin.bat). Puis écrivez à la main project.yaml, plugins/<name>/module.yaml, et le module démo.

Implémentez une task à la fois : data classes → wrappers Git/IO → pipeline → checks → actions de task → câblage plugin.yaml.

4. Valider contre un module démo

Le module démo active le plugin avec une configuration réaliste et consomme ce qu'il publie :

# demo-app/module.yaml
product: jvm/app

plugins:
  release:
    enabled: true
    tagPrefix: "v"
    initialVersion: "0.1.0"
    releaseBranchPattern: "main|master"

settings:
  jvm:
    mainClass: com.example.demo.MainKt
    jdk:
      version: 21
  kotlin:
    languageVersion: 2.1
// demo-app/src/main.kt
package com.example.demo

private const val VERSION_RESOURCE = "/META-INF/release/version.txt"

fun main() {
    val version = readVersionFromClasspath() ?: "(version unavailable)"
    println("demo-app version: $version")
}

private fun readVersionFromClasspath(): String? =
    object {}.javaClass.getResourceAsStream(VERSION_RESOURCE)
        ?.bufferedReader()
        ?.use { it.readText().trim() }
        ?.takeIf { it.isNotEmpty() }

Puis exécutez un scénario réel depuis la documentation du plugin source, et mettez les commandes dans le README afin que les consommateurs puissent la reproduire :

./kotlin run :demo-app            # => demo-app version: 0.1.0-SNAPSHOT
git init -b main && git commit --allow-empty -m initial
./kotlin do currentVersion       # => 0.1.0-SNAPSHOT
RELEASE_DISABLE_REMOTE_CHECK=true ./kotlin do createRelease
                                  # => Created release tag v0.1.0
./kotlin do currentVersion       # => 0.1.0
git commit --allow-empty -m next
./kotlin do currentVersion       # => 0.1.1-SNAPSHOT
RELEASE_FORCE_VERSION=2.0.0 ./kotlin do currentVersion
                                  # => 2.0.0

Mapping de concepts

Concept Gradle Équivalent Kotlin Toolchain Notes
Classe Plugin<Project> pluginInfo.id + settingsClass dans module.yaml, product: jvm/amper-plugin Un module par plugin ; pas de apply().
Sous-classe Task avec méthode @TaskAction fun top-level annotée @TaskAction Un par fichier dans src/tasks/.
extensions.create("foo", FooExtension::class) Interface @Configurable Settings Valeurs par défaut dans les getters de l'interface ; blocs imbriqués → interfaces @Configurable imbriquées.
task.dependsOn(otherTask) @Input sur une task correspondant à @Output d'une autre Le DAG est déduit du matching de chemin.
project.version = scmVersion.version Une task écrivant version.txt dans son @Output ; les consommateurs build-time déclarent @Input sur le même chemin Pas d'état partagé au niveau du projet ; le système de fichiers est le canal.
-Prelease.forceVersion=X RELEASE_FORCE_VERSION=X lu via System.getenv() Pas d'équivalent -P.
App lisant la version au runtime Répertoire @Output enregistré sous generated.resources ; lu via getResourceAsStream Le même fichier sert les consommateurs build-time et runtime.
Source Kotlin générée generated.sources pointant vers @Output d'une task Préférez les ressources quand la valeur est seulement lue au runtime.
Nom de task public invoqué par les utilisateurs Entrée dans commands:, invoquée comme ./kotlin do <name> Les tasks sont internes ; les commands sont l'API. Les contributeurs du graphe de build restent dehors.
Project.afterEvaluate { }, Provider/Property lazy Pas d'équivalent — les settings sont statiques Calculez les valeurs dérivées dans le corps de l'action.
Hooks DSL Groovy/Kotlin (pre { }, fileUpdate { }, commit { }) Nouvelles @TaskActions livrées avec le plugin Pas de point d'extension basé sur closure.
dependencies { implementation(...) } dependencies: dans plugins/<name>/module.yaml Mêmes coordonnées ; suffixes : exported, : runtime-only, : compile-only.
Types de task personnalisés dans buildSrc Module jvm/amper-plugin sous plugins/<name>/ Locale uniquement ; pas de publication Maven pour l'instant.
OutputDirectory / OutputFile @Output sur un paramètre Path Le répertoire est créé pour vous.
InputDirectory / InputFile / InputFiles @Input sur un paramètre Path Snapshottée pour l'évitement d'exécution.
outputs.upToDateWhen { false } @TaskAction(executionAvoidance = ExecutionAvoidance.Disabled) Pour les entrées Git/réseau/env.
Configuration avec résolution personnalisée, taskGraph.whenReady, logging quiet { } Pas d'équivalent Chaque task tire de la liste de dépendances du module plugin ; le graphe de build n'est pas introspectable ; utilisez println.

Les constructs mappés

project.yaml — rendre le plugin résolvable

Liste chaque module, plugins inclus, et pointe vers la source du plugin :

modules:
  - demo-app
  - plugins/release

plugins:
  - ./plugins/release

Sans le bloc plugins: au niveau racine, le plugins: { release: enabled } d'un consommateur ne peut pas résoudre l'id.

Settings — l'équivalent de l'extension

L'extension myPlugin { ... } de Gradle devient une interface @Configurable. Les valeurs par défaut vivent dans les getters de propriété ; les blocs DSL imbriqués deviennent des interfaces @Configurable imbriquées.

package com.example.release

import org.jetbrains.amper.plugins.Configurable

@Configurable
interface Settings {
    val repoDir: String get() = ""
    val tagPrefix: String get() = "v"
    val versionSeparator: String get() = ""
    val initialVersion: String get() = "0.1.0"
    val ignoreUncommittedChanges: Boolean get() = false
    val releaseBranchPattern: String get() = "main|master"
    val checks: ChecksSettings
}

@Configurable
interface ChecksSettings {
    val uncommittedChanges: Boolean get() = true
    val aheadOfRemote: Boolean get() = true
    val snapshotDependencies: Boolean get() = true
}

Les consommateurs définissent ce dont ils ont besoin dans module.yaml, clés par pluginInfo.id ; les valeurs omises retombent sur la valeur par défaut du getter :

plugins:
  release:
    enabled: true
    tagPrefix: "v"
    initialVersion: "0.1.0"
    ignoreUncommittedChanges: false
    checks:
      aheadOfRemote: true

@TaskAction — l'équivalent de Task

Une sous-classe Gradle Task devient une fun top-level par fichier sous src/tasks/. Les paramètres Path portent @Input ou @Output ; l'objet settings est câblé séparément dans plugin.yaml.

package com.example.release.tasks

import com.example.release.Settings
import com.example.release.git.GitRepo
import com.example.release.version.VersionPipeline
import org.jetbrains.amper.plugins.Input
import org.jetbrains.amper.plugins.TaskAction
import java.nio.file.Path

@TaskAction
fun currentVersion(
    @Input moduleRootDir: Path,
    settings: Settings,
) {
    val pipeline = VersionPipeline(settings)
    GitRepo.open(moduleRootDir, settings.repoDir).use { repo ->
        println(pipeline.infer(repo).version)
    }
}

plugin.yaml — registre de task et commandes

Chaque bloc action: câble les paramètres d'une @TaskAction, adressant la fonction par nom pleinement qualifié en forme de tag YAML. ${module.rootDir}, ${taskOutputDir}, et ${pluginSettings} sont les références documentées, et ${tasks.<task>.action.<param>} fait une référence croisée au paramètre d'une autre task.

tasks:
  currentVersion:
    action: !com.example.release.tasks.currentVersion
      moduleRootDir: ${module.rootDir}
      settings: ${pluginSettings}

  writeVersion:
    action: !com.example.release.tasks.writeVersion
      moduleRootDir: ${module.rootDir}
      outputDir: ${taskOutputDir}
      settings: ${pluginSettings}

  release:
    action: !com.example.release.tasks.release
      moduleRootDir: ${module.rootDir}
      settings: ${pluginSettings}

generated:
  resources:
    - directory: ${tasks.writeVersion.action.outputDir}

# `writeVersion` reste hors de commands: son @Output alimente generated.resources,
# donc il s'exécute déjà chaque fois que quelque chose en aval a besoin du fichier de version.
commands:
  - currentVersion
  - release

Chaque action prenant un paramètre Settings a besoin de sa propre ligne settings: ${pluginSettings} ; l'omettant passe null.

Des refonte, pas des ports

Trois fonctionnalités Gradle nécessitent une refonte consciente à chaque fois.

Pas de project.version

Transformez la valeur en fichier : une @TaskAction écrit version.txt dans son @Output ; les consommateurs build-time déclarent @Input sur ce chemin, les consommateurs runtime la lisent du classpath après que le répertoire soit enregistré sous generated.resources. Le code est dans references/examples.md.

Pas de propriétés -P

Lisez les surcharges éphémères de l'environnement à l'intérieur de l'action. Prenez la carte env comme paramètre de constructeur plutôt que d'appeler System.getenv() dans les méthodes imbriquées, afin que les tests puissent injecter une carte contrôlée :

class VersionPipeline(
    private val settings: Settings,
    private val env: Map<String, String?> = System.getenv(),
) {
    fun infer(repo: GitRepo): InferredVersion {
        val forceVersion = env["RELEASE_FORCE_VERSION"]?.takeIf { it.isNotBlank() }
        val forceSnapshot = env["RELEASE_FORCE_SNAPSHOT"].asBoolean()
        // ...
    }
}

private fun String?.asBoolean(): Boolean =
    this != null && this.equals("true", ignoreCase = true)

Nommez les variables <PLUGINID>_<UPPERCASE> et documentez le mapping dans le README :

-Prelease.forceVersion=X              → RELEASE_FORCE_VERSION=X
-Prelease.forceSnapshot               → RELEASE_FORCE_SNAPSHOT=true
-Prelease.disableChecks               → RELEASE_DISABLE_CHECKS=true
-Prelease.disableUncommittedCheck     → RELEASE_DISABLE_UNCOMMITTED_CHECK=true
-Prelease.disableRemoteCheck          → RELEASE_DISABLE_REMOTE_CHECK=true
-Prelease.overriddenBranchName=X      → RELEASE_OVERRIDDEN_BRANCH_NAME=X

La configuration statique passe toujours par module.yaml ; les variables d'env sont seulement pour les surcharges éphémères.

Contenu du README

Au-delà du démarrage rapide et de la référence des settings habituels, le README d'un port a besoin de : la table de mapping -P → var env, une liste « What's not in MVP », et la validation décrite ci-dessus.

Documentation du plugin : https://kotlin-toolchain.org/dev/user-guide/plugins/

Skills similaires