diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5a6ccf6..d3564e8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,5 @@ # CI(docs/チェックリスト.md §7)。基本は ubuntu(ルート build / integration-test のモジュールビルド / -# TestKit の分割実行)で、OS 分岐は必要な範囲のみ: macos = Apple ターゲットのネイティブツールチェーン確認 +# TestKit の分割実行 / Maven ビルド)で、OS 分岐は必要な範囲のみ: macos = Apple ターゲットのネイティブツールチェーン確認 # (ルート build のみ)・windows = mingwX64 の実行テストのみ(native の box 実行はホスト依存ゲートで # Windows でしか走らない)。main への push では加えて依存グラフを登録する。 # アクションはコミットハッシュで固定する(末尾コメントの vX.Y.Z は参照用。更新は Dependabot が追随する) @@ -67,8 +67,8 @@ jobs: key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} - run: ./gradlew build - # integration-test は 8 モジュールのビルドと TestKit テストを別ジョブへ分ける。 - # 同一ジョブだと両者がランナーのコアを奪い合い、TestKit の開始が待たされる + # integration-test は 9 モジュールのビルドと、重いテスト(TestKit / Maven)を別ジョブへ分ける。 + # 同一ジョブだと互いにランナーのコアを奪い合い、開始が待たされる integration-modules: runs-on: ubuntu-latest timeout-minutes: 90 @@ -83,7 +83,9 @@ jobs: with: path: ~/.konan key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} - - run: ./gradlew -p integration-test build -x :gradle-integration:test + - run: >- + ./gradlew -p integration-test build + -x :gradle-integration:test -x :maven-integration:test # TestKit テストはランナーを増やして分割する。ランナーのコア数ではジョブ内の並行度を上げても # 実行時間が縮まらないため(同時実行 2 で 1 割・4 で頭打ち)、ジョブ内は直列にして分割数で稼ぐ。 @@ -112,6 +114,27 @@ jobs: ./gradlew -p integration-test :gradle-integration:test --rerun -PtestShardIndex=${{ matrix.shard }} -PtestShardCount=3 -PtestKitParallelism=1 + # Maven ビルドの検証(docs/test/ケース06-ビルド動態.md §7)。Maven 本体は Gradle 側の依存として + # 取得するためインストール手順は要らない。フィクスチャは Kotlin 一式をテスト専用のローカルリポジトリ + # (~/.m2/repository とは別。docs/test/フィクスチャ構成.md §6)へ取得するため、そこをキャッシュする + integration-maven: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: integration-test/maven-integration/build/maven-local-repo + key: maven-repo-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} + # フィクスチャの入力にはローカル Maven の非タイムスタンプ SNAPSHOT が含まれ UP-TO-DATE 判定が + # 信用できないため --rerun で必ず再実行する(TestKit と同じ理由) + - run: ./gradlew -p integration-test :maven-integration:test --rerun + build-macos: runs-on: macos-latest timeout-minutes: 60 diff --git a/README.md b/README.md index b4ae007..1cec136 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ operational API of enums on top. ## Setup +### Gradle + ```kotlin plugins { kotlin("multiplatform") version "2.4.10" // or kotlin("jvm") — any Kotlin target plugin @@ -38,6 +40,54 @@ sealedClassEnumizer { } ``` +### Maven + +Declare the plugin as a dependency of kotlin-maven-plugin and name it in `compilerPlugins`; the +compiler plugin follows as a transitive dependency. It applies to every kotlin-maven-plugin +execution, production and test compilations alike. + +```xml + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + sealed-class-enumizer + + + + + io.github.projectmapk + sealed-class-enumizer-maven-plugin + 2.4.10-0.1.0 + + + +``` + +Unlike the Gradle plugin, the runtime API is not added for you — declare it as a project +dependency (Maven resolves the platform artifact, hence the `-jvm` suffix): + +```xml + + io.github.projectmapk + sealed-class-enumizer-runtime-api-jvm + 2.4.10-0.1.0 + +``` + +The project-wide default label case is a property: + +```xml + + AS_DECLARED + +``` + +The same option can be given through kotlin-maven-plugin's `pluginOptions` +(`sealed-class-enumizer:labelCase=...`), which takes precedence over the property. + ### IntelliJ IDEA IntelliJ's K2 mode does not load third-party compiler plugins by default, so generated @@ -161,7 +211,8 @@ Labels default to the leaf's simple name and resolve with this priority: 1. `@EnumishLabel("...")` on the leaf — used as-is (no case conversion); for resolving label clashes and keeping persisted labels stable across renames 2. `@Enumize(labelCase = ...)` on the hierarchy -3. The project-wide default from the Gradle DSL (`sealedClassEnumizer { labelCase = ... }`) +3. The project-wide default from the build — the Gradle DSL `sealedClassEnumizer { labelCase }` + or the Maven `sealed-class-enumizer.labelCase` property 4. `AS_DECLARED` (no conversion) Available cases: `AS_DECLARED`, `UPPER_SNAKE_CASE`, `SNAKE_CASE`, `KEBAB_CASE`. @@ -176,7 +227,7 @@ Label uniqueness within a hierarchy is enforced at compile time. |---|---| | Kotlin | 2.4.x, K2 only. The plugin version encodes the Kotlin minor it targets; applying it to another Kotlin minor produces a build warning | | Version format | `-`, e.g. `2.4.10-0.1.0` | -| Build environment | Gradle 9+ / JDK 17+ (verified with Gradle 9.6) | +| Build environment | Gradle 9+ or Maven 3.9+ / JDK 17+ (verified with Gradle 9.6 and Maven 3.9) | | runtime-api (JVM) | Java 8+ bytecode | | runtime-api targets | `jvm`, `js`, `wasmJs`, `wasmWasi`, `linuxX64`, `mingwX64`, `macosX64`, `macosArm64`, `iosArm64`, `iosSimulatorArm64`, `iosX64` | @@ -192,8 +243,8 @@ Label uniqueness within a hierarchy is enforced at compile time. ## Versioning and support policy - Versions follow `-`. - All published artifacts (runtime API, compiler plugin, Gradle plugin and its marker) share the - same version. + All published artifacts (runtime API, compiler plugin, Gradle plugin and its marker, Maven + plugin) share the same version. - The Kotlin compiler plugin API has no stability guarantee, so each release targets exactly one Kotlin minor; support covers the latest stable Kotlin minor including its patch releases. - New Kotlin releases are followed by new plugin releases; older minors receive no backports. diff --git a/build.gradle.kts b/build.gradle.kts index 88dce66..13a9c22 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,8 +40,9 @@ allprojects { ktfmt { kotlinLangStyle() } } -// API ドキュメント(GitHub Pages 掲載用)の集約。対象は公開 API を持つ 2 モジュールで、 -// compiler-plugin は内部実装のため含めない。集約出力は build/dokka/html +// API ドキュメント(GitHub Pages 掲載用)の集約。対象は利用者がコードから触る公開 API を持つ 2 モジュールで、 +// compiler-plugin(内部実装)と maven-plugin(利用面は POM の設定であり Kotlin API ではない)は含めない。 +// 集約出力は build/dokka/html dependencies { dokka(project(":sealed-class-enumizer-runtime-api")) dokka(project(":sealed-class-enumizer-gradle-plugin")) diff --git "a/docs/test/\343\202\261\343\203\274\343\202\27106-\343\203\223\343\203\253\343\203\211\345\213\225\346\205\213.md" "b/docs/test/\343\202\261\343\203\274\343\202\27106-\343\203\223\343\203\253\343\203\211\345\213\225\346\205\213.md" index 32f147b..fc6a32a 100644 --- "a/docs/test/\343\202\261\343\203\274\343\202\27106-\343\203\223\343\203\253\343\203\211\345\213\225\346\205\213.md" +++ "b/docs/test/\343\202\261\343\203\274\343\202\27106-\343\203\223\343\203\253\343\203\211\345\213\225\346\205\213.md" @@ -1,8 +1,9 @@ # ケース06 ビルド動態 -本資料は K14(ビルド動態)× O2/O5 の正典である。 +本資料は K14(ビルド動態)× K17(ビルドツール)× O2/O5 の正典である。 編集ケース #1〜#12(7-b/7-c 含む)・@Enumize 付与除去・決定性・基底不在ラウンド・ -空⇔非空遷移・跨 module ABI 伝播・旧バイナリ差し替え・runtime-api 公開・MPP klib ラウンドを担う。 +空⇔非空遷移・跨 module ABI 伝播・旧バイナリ差し替え・runtime-api 公開・MPP klib ラウンド・ +Maven ビルドを担う。 実行時 OUT 照合(O1)は clean 一致判定の手段として併用する。 他資料の IC・決定性に関する観測は本資料への参照で足りる(テスト戦略 §3 C4)。 @@ -83,3 +84,16 @@ | ID | 次元/値 | 観測と期待 | 実装 | 備考(関連V/診断) | |---|---|---|---|---| | BLD-48 | K14/K16=プロジェクト既定の伝達 | DSL `sealedClassEnumizer { labelCase = SNAKE_CASE }` がコンパイラオプション(labelCase。初のオプション経路)として届き、labelCase 未指定の階層へ適用される・@Enumize の具体指定(KEBAB_CASE)は DSL に勝つ。検証は label-case フィクスチャ内 JUnit(test タスク)の成功で行う | LabelCaseOptionTest#dslDefaultPropagatesToCompilerOption | convention 側(DSL 未設定 = AS_DECLARED)はケース01 API-55 | + +## 7. Maven ビルド + +Gradle 以外のビルドツール上での適用を担う(実 Maven ビルドで駆動。フィクスチャは +[フィクスチャ構成](フィクスチャ構成.md) §6)。 + +| ID | 次元/値 | 観測と期待 | 実装 | 備考(関連V/診断) | +|---|---|---|---|---| +| BLD-49 | K17=適用範囲 | `` へ記載すると kotlin-maven-plugin の compile / test-compile の双方へ適用され(適用ログ 2 件)、main / test 双方の階層で生成 API が解決・実行できる。label はプロジェクト既定を置かない構成で組み込み既定(AS_DECLARED) | MavenBuildTest#appliesToProductionAndTestCompilations | コンパイラプラグイン本体は maven-plugin の推移依存として載る・runtime-api は利用側宣言(概要 §7) | +| BLD-50 | K17/K16=プロジェクト既定の伝達 | プロパティ `sealed-class-enumizer.labelCase` の値がコンパイラオプションとして届き、labelCase 未指定の階層へ適用される・@Enumize の具体指定はプロジェクト既定に勝つ | MavenBuildTest#projectDefaultLabelCaseReachesTheCompiler | Gradle 側の対応は BLD-48 | +| BLD-51 | K17/K16=実行単位指定の優先 | `` の `sealed-class-enumizer:labelCase=...` がプロジェクト既定に優先する(拡張側が渡さないことで成立する。コンパイラのオプションは複数指定を受け付けない) | MavenBuildTest#executionLevelPluginOptionOverridesTheProjectDefault | 二重指定はコンパイラ側の失敗になるため、優先関係は拡張の抑止で実現する | +| BLD-52 | K17×O2=診断のビルド失敗 | 非 sealed 宣言への `@Enumize` が ENUMIZE_NOT_SEALED としてコンパイルを失敗させ、Maven ビルドが失敗する | MavenBuildTest#compilerDiagnosticFailsTheBuild | 診断内容の正典はケース04 | +| BLD-53 | K17=設定値の不正 | labelCase プロパティの不正値は、受け付ける値の一覧を添えたビルド失敗になる(コンパイラへ渡る前に設定面の名前を示す) | MavenBuildTest#unknownLabelCasePropertyFailsTheBuild | — | diff --git "a/docs/test/\343\203\206\343\202\271\343\203\210\346\210\246\347\225\245.md" "b/docs/test/\343\203\206\343\202\271\343\203\210\346\210\246\347\225\245.md" index 4977556..2cc16c8 100644 --- "a/docs/test/\343\203\206\343\202\271\343\203\210\346\210\246\347\225\245.md" +++ "b/docs/test/\343\203\206\343\202\271\343\203\210\346\210\246\347\225\245.md" @@ -8,10 +8,10 @@ ## 1. 検証対象と非対象 -- 検証対象は CLI / Gradle ビルドの成否・診断・生成物・実行時挙動に限る。 +- 検証対象は CLI / Gradle / Maven ビルドの成否・診断・生成物・実行時挙動に限る。 IDE(K2) はサードパーティ FIR プラグインを既定でロードしない(KTIJ-29248)ため、 IDE / Analysis API 側は検証対象外とする(既定の IDE 体験は概要 §6・設計00 §11 が既知の制限として持つ)。 -- 環境: Kotlin 2.4.10 / Gradle 9.6.1 / JVM toolchain 17 / `group=io.github.projectmapk`。 +- 環境: Kotlin 2.4.10 / Gradle 9.6.1 / Maven 3.9.12 / JVM toolchain 17 / `group=io.github.projectmapk`。 - 比較規約: 値の比較はなるべく観測用 data class を組み立て 1 回の `assertEquals` で行う。 kind の同一性は参照比較(`===`)で行う。 @@ -38,6 +38,7 @@ Kotlin 言語仕様がこのプラグインに対して張る空間を次の次 | K14 | ビルド動態 | clean / 編集ケース #1〜#12 / @Enumize 付与・除去 / 決定性 4 形態+ロケール / 基底不在ラウンド / 空⇔非空遷移 | | K15 | 実行時状態 | 遅延初期化 / 初期化子再入 / マルチスレッド初回 / boxing / SAM 変換 / 多段サブタイプ / 無名 object・local クラスによる末端実装 | | K16 | label 決定 | labelCase(未指定 / PROJECT_DEFAULT 明示 / 具体 4 種)× 明示 @EnumishLabel(無 / 有 / 空白 / 不正な付与先 4 形 = 基底・中間 sealed・階層外・kind companion)× プロジェクト既定(DSL 指定 / convention / 組み込み既定)‖ 衝突(変換由来・明示由来)・enum 末端 / companion 末端への一律適用・data object toString の乖離 | +| K17 | ビルドツール | Gradle(`KotlinCompilerPluginSupportPlugin`)/ Maven(kotlin-maven-plugin 拡張)‖ 各ツールの設定面: 適用範囲(production / test)・プロジェクト既定の label ケース・実行単位のオプション指定・runtime-api の供給(Gradle=自動 / Maven=利用側宣言)・設定値の不正 | 観測面(1 ケースは 1 つ以上の観測面を持つ): @@ -67,9 +68,11 @@ Kotlin 言語仕様がこのプラグインに対して張る空間を次の次 ## 4. テストモジュール構成 -integration-test は親ビルドを composite 参照する独立 Gradle ビルドであり、8 モジュールから成る。 +integration-test は親ビルドを composite 参照する独立 Gradle ビルドであり、9 モジュールから成る。 「コンパイル成功で証明できる正値」は通常モジュール、「コンパイル失敗・2 回ビルド差分・バイト一致・別版組合せ」は -TestKit(gradle-integration)に置く。 +TestKit(gradle-integration)に置く。 +Gradle 以外のビルドツール上の適用は専用モジュール(maven-integration)が担い、 +ツール本体を配布物として取得して実ビルドを駆動する(ホストへのインストールを前提にしない)。 | モジュール | プラグイン | 役割 | |---|---|---| @@ -81,6 +84,7 @@ TestKit(gradle-integration)に置く。 | mpp-producer | 適用 | MPP 生成側。common フィクスチャ+全 5 ターゲット box・klib/metadata(V5) | | mpp-consumer | 未適用 | MPP 純消費。common からの生成 API 参照・跨 module×MPP の when | | gradle-integration | TestKit | 診断(負値・near-miss)・IC 回帰・決定性・ABI 伝播・旧バイナリ・基底不在ラウンド | +| maven-integration | Maven 駆動 | kotlin-maven-plugin 拡張としての適用範囲・label ケースの伝達と優先・診断と設定不正のビルド失敗(K17) | ## 5. 状態と ID 規約 diff --git "a/docs/test/\343\203\225\343\202\243\343\202\257\343\202\271\343\203\201\343\203\243\346\247\213\346\210\220.md" "b/docs/test/\343\203\225\343\202\243\343\202\257\343\202\271\343\203\201\343\203\243\346\247\213\346\210\220.md" index 253d45f..5b74cca 100644 --- "a/docs/test/\343\203\225\343\202\243\343\202\257\343\202\271\343\203\201\343\203\243\346\247\213\346\210\220.md" +++ "b/docs/test/\343\203\225\343\202\243\343\202\257\343\202\271\343\203\201\343\203\243\346\247\213\346\210\220.md" @@ -124,3 +124,32 @@ src/test には test compilation 配線検証用の小さな @Enumize 階層(T - 順序フィクスチャの期待値は FQN の UTF-16 序数から機械的に導出し、期待列をコメントで併記する。 - producer-jvm のフィクスチャで「1 ファイル 1 クラス」規約より配置制約(同一ファイル要求・ 複数トップレベル宣言)が優先される場合は、ファイル先頭コメントで適用外を明示する。 + +## 6. maven-integration(Maven フィクスチャ) + +フィクスチャは 1 プロジェクト = 1 POM の合成 Maven プロジェクトであり、実 Maven ビルドで駆動する。 + +| フィクスチャ | 構成 | 駆動内容 | +|---|---|---| +| basic | main = labelCase 未指定階層 + 具体指定階層(KEBAB_CASE)・test = 独自階層 + JUnit ‖ プロファイル 2 種(プロジェクト既定のプロパティ・実行単位の `` 指定) | 適用範囲(production / test の両コンパイレーション)と label ケースの決定(組み込み既定 / プロジェクト既定 / 実行単位指定 / 階層個別の具体指定)を実行時 OUT 行で照合 | +| diag | 非 sealed 宣言への `@Enumize` ‖ プロファイル(labelCase の不正値) | 診断と設定不正値がビルド失敗として表面化することの照合 | + +運用方針: + +- プラグイン注入は local-repo 経路(前段で `publishAllToMavenLocal`)。 + ただし Maven のローカルリポジトリは**テスト専用の場所へ隔離**し、本プロジェクトの成果物だけを + そこへ複製して解決する(複製は MavenHarness)。 + 既定の `~/.m2/repository` を使うと、Maven が取得する Gradle module metadata を伴わない成果物が + そこへ入り、`mavenLocal()` を宣言する TestKit フィクスチャの variant 解決(capability による + `kotlin-test` の選択など)を壊すためである。 +- フィクスチャは版を直書きせず、`%%ENUMIZER_VERSION%%` / `%%KOTLIN_VERSION%%` を展開時に置換する + (値の正は親ビルド。展開は MavenHarness が行う)。 +- Maven 本体は配布物(`org.apache.maven:apache-maven`)をビルドの依存として取得し、 + ホストへのインストールを前提にしない。 + 起動は `bin` のスクリプトではなく classworlds の Launcher を直接叩く(zip + 展開では実行権限が落ちるため)。JVM はテストと同じ toolchain を使う。 +- 実行は直列とする(Maven のローカルリポジトリは共有資源であり、並行ダウンロードでの破損を避ける)。 +- 展開先は実行前に回収する(§4 と同じ)。 +- 検証手段は 2 種: ビルドの成否と出力照合(適用ログ・診断文言)・フィクスチャ内 JUnit が出す OUT 行。 + 設定で変わる値(label)は OUT 行で照合し、設定に依らない不変はフィクスチャ内で表明する。 +- runtime-api は Maven では自動追加されないため(概要 §7)、フィクスチャが依存として明示宣言する。 diff --git "a/docs/test/\344\277\235\347\225\231.md" "b/docs/test/\344\277\235\347\225\231.md" index e64cfd1..35623c0 100644 --- "a/docs/test/\344\277\235\347\225\231.md" +++ "b/docs/test/\344\277\235\347\225\231.md" @@ -14,7 +14,7 @@ | PEN-01 | (欠番) | IDE / Analysis API は検証対象外と判断(既定の IDE 体験は概要 §6・設計00 §11 が既知の制限として持つ) | — | — | | PEN-02 | (欠番) | V1-(e) は現行挙動で仕様確定(概要 §8 に明文化・ManualImplTest / CrossModuleWhenTest / CrossModuleNegativeTest が固定済み) | — | — | | PEN-03 | (欠番) | V7 は BLD-45 で実測確定 | — | — | -| PEN-04 | V8 環境マトリクス | 階層不変条件は JVM+Gradle 実測のみ(JPS / Maven / JS・Native klib IC / Native クロスホスト / Android+R8 / Native リリース DCE / js・wasm の明示 DCE リンク経路 / klib の旧バイナリ差し替えは未検証。MPP の from-cache/relocated 一致は BLD-44 が担う) | 各環境の IC 共連れ実測・Native 決定性・release/明示 DCE リンクでの $EntriesHolder 保持・klib 差し替えの実行時解決 | 各環境の駆動ハーネス整備(不成立環境はサポート対象外と明記して解除) | +| PEN-04 | V8 環境マトリクス | 階層不変条件は JVM+Gradle 実測のみ(JPS / Maven / JS・Native klib IC / Native クロスホスト / Android+R8 / Native リリース DCE / js・wasm の明示 DCE リンク経路 / klib の旧バイナリ差し替えは未検証。MPP の from-cache/relocated 一致は BLD-44 が担う。Maven は適用・オプション伝達・診断を BLD-49〜53 で実測済みで、残るのは IC 面のみ) | 各環境の IC 共連れ実測・Native 決定性・release/明示 DCE リンクでの $EntriesHolder 保持・klib 差し替えの実行時解決 | 各環境の駆動ハーネス整備(不成立環境はサポート対象外と明記して解除) | | PEN-05 | (欠番) | E-1 成立モードは将来拡張として PEN-07 へ統合(v1 の全構成 MISMATCH は DIA-49 が固定) | — | — | | PEN-06 | (欠番) | 縮退モード駆動は縮退未発生のため対象外と判断(現実化すれば成立側テスト群の失敗として検知される) | — | — | | PEN-07 | 将来拡張(拡張時検証) | v1 非対応の拡張群: 追加基底 interface 注入・手動 `Enumized` の K 採用(E-1。位相リスクの代替=@Enumize の KClass パラメータ案)。検証は各拡張の着手時に行う | 追加基底一括注入・K 採用の成立側(E-1 は位相スパイクから始める) | 各拡張の着手(仕様確定→検証→ケース資料へ展開。K 採用は位相不成立確定なら MISMATCH 維持で本行から除く) | diff --git "a/docs/\345\256\237\350\243\205\343\203\216\343\203\274\343\203\210.md" "b/docs/\345\256\237\350\243\205\343\203\216\343\203\274\343\203\210.md" index 844d352..464803b 100644 --- "a/docs/\345\256\237\350\243\205\343\203\216\343\203\274\343\203\210.md" +++ "b/docs/\345\256\237\350\243\205\343\203\216\343\203\274\343\203\210.md" @@ -11,11 +11,14 @@ - `runtime-api`(MPP: jvm / js / wasmJs / wasmWasi / linuxX64 / mingwX64 / macosX64 / macosArm64 / iosArm64 / iosSimulatorArm64 / iosX64)・`compiler-plugin`・`gradle-plugin`(plugin id - `io.github.projectmapk.sealed-class-enumizer`)。 + `io.github.projectmapk.sealed-class-enumizer`)・`maven-plugin`(kotlin-maven-plugin の拡張。 + 利用側が `` へ書く名前は `sealed-class-enumizer`)。 パッケージルートは `io.github.projectmapk.sealedClassEnumizer`(group=io.github.projectmapk に統一)。 -- `compiler-plugin` の単体テストは名前定数の一致検証とケース変換の期待値固定のみで、 - 生成挙動の検証は独立 composite build の `integration-test`([テスト戦略](test/テスト戦略.md) §4 の 8 - モジュール)が持つ。 +- コンパイラプラグイン ID はビルドの単一情報源(ルート `gradle.properties`)から両プラグインの + Coordinates を生成する。Gradle の plugin id もこれに揃える。 +- `compiler-plugin` / `gradle-plugin` / `maven-plugin` の単体テストは定数と登録の一致検証や + 期待値固定に限り、生成挙動とビルドツール上の適用の検証は独立 composite build の + `integration-test`([テスト戦略](test/テスト戦略.md) §4 の 9 モジュール)が持つ。 - 仕様と実挙動の乖離は[修正方針案](修正方針案.md)が持つ。 該当テストは仕様どおりのアサートのまま `@Ignore` / `@Disabled` とする。 diff --git "a/docs/\346\246\202\350\246\201.md" "b/docs/\346\246\202\350\246\201.md" index 5960cd4..50e4e1b 100644 --- "a/docs/\346\246\202\350\246\201.md" +++ "b/docs/\346\246\202\350\246\201.md" @@ -507,6 +507,7 @@ kind の `toString` は label getter 経由のため自動で追随するが、d | `runtime-api` | `@Enumize`・label カスタマイズ(`@EnumishLabel` / `LabelCase`。§4)・基底型(`Enumish` / `EnumishCompanion` / `Enumized`)・`EntriesHolder` の基底クラス(`EnumishEntriesHolder`)・拡張プロパティ(`Enumized.label`)(§2。MPP・reflection 非依存) | | `compiler-plugin` | K2 コンパイラプラグイン本体(FIR + IR)。オプション `labelCase`(プロジェクト既定の label ケース。具体ケースのみ受け付け、欠落時は AS_DECLARED。§4) | | `gradle-plugin` | `KotlinCompilerPluginSupportPlugin`。全コンパイレーション(main / test / metadata / 全ターゲット)へ適用し、`runtime-api` を自動追加(production = main / metadata は生成 API が supertype として runtime-api の型を公開する ABI 依存のため **api スコープ**、test コンパイレーションは公開面を持たないため implementation。オプションで無効化可)。DSL の `labelCase`(convention = AS_DECLARED)を常に具体値でコンパイラオプションへ伝達する(§4) | +| `maven-plugin` | `KotlinMavenPluginExtension`(kotlin-maven-plugin の拡張であり Mojo ではない)。`` へ記載したコンパイレーション(production / test)へ適用し、コンパイラプラグイン本体を推移依存として配る。プロジェクト既定の label ケースはプロパティ `sealed-class-enumizer.labelCase` から常に具体値でコンパイラオプションへ伝達する(§4。実行単位の `` に同じ指定がある場合はそちらを優先し、オプションの二重指定を避けるため何も渡さない)。`runtime-api` の自動追加は行わず、利用側が依存として宣言する(Maven では拡張からプロジェクト依存を追加できず、追加にはビルド拡張の別途宣言を要するため) | - **K2 のみ**サポート。 実用下限は **Kotlin 2.4**(生成クラス内ネストの解決に関する Analysis API 側の不具合 KT-84625 が 2.4.0-Beta2 @@ -514,10 +515,11 @@ kind の `toString` は label getter 経由のため自動で追随するが、d - コンパイラプラグイン API は「リリース毎に破壊的変更が入る」ことが公式に宣言されているた め、**Kotlin のマイナーバージョン毎にアーティファクトを分割**して配布する。 分割は版番号で実現し、版形式は `-<自版>`(例: `2.4.10-0.1.0`)。 - 3 モジュールと plugin marker は常に同版で公開し、公開 artifactId は `sealed-class-enumizer-` 接頭辞付き + 4 モジュールと plugin marker は常に同版で公開し、公開 artifactId は `sealed-class-enumizer-` 接頭辞付き (group `io.github.projectmapk` の直下に汎用名を置かない)。 サポートは最新の安定 Kotlin マイナー(とそのパッチ)のみで、旧マイナーへのバックポートは行わない。 - 対応外の Kotlin への適用は gradle-plugin が警告する(エラーにはしない) + 対応外の Kotlin への適用は gradle-plugin / maven-plugin が警告する(エラーにはしない。適用先の Kotlin + 版は、Gradle では KGP の版・Maven では kotlin-maven-plugin の版として読む) - 階層(sealed 基底から末端までの範囲)は言語仕様上、同一パッケージ・同一モジュール・ (MPP では)同一ソースセットに閉じる。 末端が非 final な場合、そのサブタイプは階層の外側にあってよい(別モジュールでもよい。§3)。 @@ -619,7 +621,7 @@ klib(JS / Native / Wasm)への直列化 — IR-only 生成物(EntriesHolde | 範囲 | 内容 | |---|---| -| IC の環境マトリクス | 階層不変条件(sealed 階層の一括再コンパイル)が JVM + Gradle 以外(JPS / Maven)でも成り立つこと(MPP は Kotlin 側が既定で非 incremental のため、この条件を要さないことを実測済み)。未検証の環境はサポート対象外と明記する | +| IC の環境マトリクス | 階層不変条件(sealed 階層の一括再コンパイル)が JVM + Gradle 以外(JPS / Maven)でも成り立つこと(MPP は Kotlin 側が既定で非 incremental のため、この条件を要さないことを実測済み)。Maven はプラグインの適用・オプション伝達・診断を実測済みであり、未検証なのは IC 面のみである。未検証の環境はサポート対象外と明記する | IDE(Analysis API)での生成 API の解決と kind 単位の網羅 when の判定は、未検証のまま**検証対象外**と判断済みである (§6 の既定体験を既知の制限として受容する)。 diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index 6159c3a..e2efef5 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -20,8 +20,8 @@ dependencies { tasks.test { useJUnitPlatform() } -// plugin id の正定義。compiler-plugin の EnumizeCommandLineProcessor.PLUGIN_ID と一致していなければならない -val pluginId = "io.github.projectmapk.sealed-class-enumizer" +// plugin id はコンパイラプラグイン ID と同一とする(値の正はルートの gradle.properties) +val pluginId = providers.gradleProperty("compilerPluginId").get() gradlePlugin { website = providers.gradleProperty("POM_URL") diff --git a/gradle.properties b/gradle.properties index 328b50f..2dd34fb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,6 +4,11 @@ kotlin.code.style=official # (ルートの build.gradle.kts が結合して全モジュールへ配る) enumizerVersion=0.1.0-SNAPSHOT +# コンパイラプラグイン ID の単一情報源。compiler-plugin の EnumizeCommandLineProcessor.PLUGIN_ID と +# 一致していなければならない。gradle-plugin / maven-plugin はビルド時生成の Coordinates 経由で参照し、 +# Gradle の plugin id もこれに揃える(利用者が書く識別子を 1 つに保つ) +compilerPluginId=io.github.projectmapk.sealed-class-enumizer + # Maven Central 公開の POM 共通値(vanniktech maven publish の規約キー)。 # モジュール別の POM_NAME / POM_DESCRIPTION は各モジュールの gradle.properties が持つ POM_URL=https://github.com/ProjectMapK/sealed-class-enumizer diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c4335d4..4ec642a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,7 @@ [versions] kotlin = "2.4.10" +# maven-plugin がコンパイル時に参照する Maven API の版(kotlin-maven-plugin 自身が依存する版に揃える) +maven = "3.9.12" autoservice = "0.1.5" ktfmt = "0.26.0" mavenPublish = "0.37.0" @@ -9,6 +11,8 @@ dokka = "2.2.0" [libraries] kotlin-compiler-embeddable = { module = "org.jetbrains.kotlin:kotlin-compiler-embeddable", version.ref = "kotlin" } kotlin-gradle-plugin-api = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin-api", version.ref = "kotlin" } +kotlin-maven-plugin = { module = "org.jetbrains.kotlin:kotlin-maven-plugin", version.ref = "kotlin" } +maven-core = { module = "org.apache.maven:maven-core", version.ref = "maven" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } diff --git a/integration-test/maven-integration/build.gradle.kts b/integration-test/maven-integration/build.gradle.kts new file mode 100644 index 0000000..f39ade7 --- /dev/null +++ b/integration-test/maven-integration/build.gradle.kts @@ -0,0 +1,87 @@ +import java.util.Properties + +// Maven ビルドホスト(docs/test/テスト戦略.md §4)。 +// resources/fixtures 配下の合成 Maven プロジェクトを実 Maven で駆動し、kotlin-maven-plugin の拡張 +// としての適用(production / test 双方)・label ケースの伝達と優先順位・診断によるビルド失敗を検証する +plugins { kotlin("jvm") } + +group = "io.github.projectmapk" + +version = "1.0-SNAPSHOT" + +kotlin { jvmToolchain(17) } + +dependencies { testImplementation(kotlin("test")) } + +// Maven 本体はビルドの依存として取得する(ホストへの Maven インストールを前提にしない)。 +// 版は親ビルドの version catalog を正とし、maven-plugin がコンパイル時に参照する Maven API と揃える +val mavenVersion: String = libs.versions.maven.get() + +val mavenDistribution = configurations.dependencyScope("mavenDistribution") + +val mavenDistributionFiles = + configurations.resolvable("mavenDistributionFiles") { extendsFrom(mavenDistribution.get()) } + +dependencies { add(mavenDistribution.name, "org.apache.maven:apache-maven:$mavenVersion:bin@zip") } + +val mavenDistRoot = layout.buildDirectory.dir("maven-dist") + +// 配布物の展開。起動は bin/mvn ではなく classworlds の Launcher を直接叩くため(MavenHarness)、 +// zip 展開で失われる実行権限に依存しない +val unpackMaven = + tasks.register("unpackMaven") { + from(zipTree(provider { mavenDistributionFiles.get().singleFile })) + into(mavenDistRoot) + } + +// フィクスチャのプレースホルダ置換(MavenHarness)へ渡す版。自版は親ビルドの gradle.properties、 +// Kotlin 版は共有カタログを正とする(docs/test/フィクスチャ構成.md §6) +val enumizerOwnVersion: String = + Properties() + .apply { rootDir.resolve("../gradle.properties").inputStream().use(::load) } + .getProperty("enumizerVersion") ?: error("../gradle.properties に enumizerVersion が無い") + +val enumizerKotlinVersion: String = libs.versions.kotlin.get() + +// Maven のローカルリポジトリはテスト専用の場所へ隔離する(docs/test/フィクスチャ構成.md §6)。 +// 既定の ~/.m2/repository を使うと、Maven が取得する Gradle module metadata を伴わない成果物が +// そこへ入り、mavenLocal() を宣言する TestKit フィクスチャの variant 解決を壊す +val mavenLocalRepo = layout.buildDirectory.dir("maven-local-repo") + +// 本プラグインの成果物は publishToMavenLocal の出力(= ローカル Maven)を正とし、 +// 隔離リポジトリへは MavenHarness が複製する +val publishedRepo = providers.systemProperty("user.home").map { "$it/.m2/repository" } + +// フィクスチャの展開先。失敗解析のため実行後は残し、次回の実行開始時に作り直す +// (docs/test/フィクスチャ構成.md §6(展開先は実行前に回収する)) +val fixtureWorkRoot = layout.buildDirectory.dir("maven-fixtures") + +val cleanFixtureWorkRoot = + tasks.register("cleanFixtureWorkRoot") { + val workRoot = fixtureWorkRoot + doLast { workRoot.get().asFile.deleteRecursively() } + } + +tasks.test { + dependsOn(cleanFixtureWorkRoot, unpackMaven) + // フィクスチャはプラグイン一式をローカル Maven から解決するため、テスト前に公開しておく + // (docs/test/フィクスチャ構成.md §6 の local-repo 経路) + dependsOn(gradle.includedBuild("sealed-class-enumizer").task(":publishAllToMavenLocal")) + useJUnitPlatform() + systemProperty( + "enumizer.mavenHome", + mavenDistRoot.get().asFile.resolve("apache-maven-$mavenVersion").absolutePath, + ) + systemProperty("enumizer.mavenLocalRepo", mavenLocalRepo.get().asFile.absolutePath) + systemProperty("enumizer.publishedRepo", publishedRepo.get()) + systemProperty("enumizer.fixtureWorkRoot", fixtureWorkRoot.get().asFile.absolutePath) + systemProperty("enumizer.version", "$enumizerKotlinVersion-$enumizerOwnVersion") + systemProperty("enumizer.kotlinVersion", enumizerKotlinVersion) + // Maven のローカルリポジトリは共有資源であり、並行ダウンロードでの破損を避けるため直列に回す + // (フィクスチャは 1 プロジェクトずつで、TestKit のような多重化の利得も無い) + maxParallelForks = 1 + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = false + } +} diff --git a/integration-test/maven-integration/src/test/kotlin/io/github/projectmapk/maven/MavenBuildTest.kt b/integration-test/maven-integration/src/test/kotlin/io/github/projectmapk/maven/MavenBuildTest.kt new file mode 100644 index 0000000..4c63576 --- /dev/null +++ b/integration-test/maven-integration/src/test/kotlin/io/github/projectmapk/maven/MavenBuildTest.kt @@ -0,0 +1,124 @@ +package io.github.projectmapk.maven + +import kotlin.test.Test +import kotlin.test.assertEquals + +// 実 Maven での適用の観測(docs/test/ケース06-ビルド動態.md BLD-49〜BLD-53)。 +// label は設定で変わる値であり、フィクスチャの JUnit が出す OUT 行で照合する +class MavenBuildTest { + private data class Applied( + val succeeded: Boolean, + // 「Applied plugin」は 1 コンパイレーションにつき 1 行。production / test の両方に載ることの観測 + val appliedPluginCount: Int, + val observations: List, + ) + + private data class Failed(val succeeded: Boolean, val messageFound: Boolean) + + @Test + fun appliesToProductionAndTestCompilations() { + val result = MavenHarness.run(MavenHarness.prepareFixture("basic"), "test") + assertEquals( + Applied( + succeeded = true, + appliedPluginCount = 2, + observations = + listOf( + // プロジェクト既定は未指定 = 組み込み既定(AS_DECLARED) + "OUT: si=Bar,FooBar", + "OUT: pinned=foo-bar", + "OUT: testOnly=OnlyLeaf", + ), + ), + observed(result), + result.output, + ) + } + + @Test + fun projectDefaultLabelCaseReachesTheCompiler() { + val result = + MavenHarness.run(MavenHarness.prepareFixture("basic"), "test", "-Pproject-default") + assertEquals( + Applied( + succeeded = true, + appliedPluginCount = 2, + observations = + listOf( + "OUT: si=BAR,FOO_BAR", + // 階層個別の具体指定はプロジェクト既定に勝つ + "OUT: pinned=foo-bar", + "OUT: testOnly=ONLY_LEAF", + ), + ), + observed(result), + result.output, + ) + } + + @Test + fun executionLevelPluginOptionOverridesTheProjectDefault() { + val result = + MavenHarness.run( + MavenHarness.prepareFixture("basic"), + "test", + "-Pproject-default,plugin-option", + ) + assertEquals( + Applied( + succeeded = true, + appliedPluginCount = 2, + observations = + listOf("OUT: si=bar,foo_bar", "OUT: pinned=foo-bar", "OUT: testOnly=only_leaf"), + ), + observed(result), + result.output, + ) + } + + @Test + fun compilerDiagnosticFailsTheBuild() { + val result = MavenHarness.run(MavenHarness.prepareFixture("diag"), "compile") + assertEquals( + Failed(succeeded = false, messageFound = true), + Failed( + result.succeeded, + result.output.contains( + "'@Enumize' is applicable only to a sealed class or a sealed interface." + ), + ), + result.output, + ) + } + + @Test + fun unknownLabelCasePropertyFailsTheBuild() { + val result = + MavenHarness.run(MavenHarness.prepareFixture("diag"), "compile", "-Pbad-label-case") + assertEquals( + Failed(succeeded = false, messageFound = true), + Failed( + result.succeeded, + result.output.contains( + "Unknown value for the 'sealed-class-enumizer.labelCase' property: " + + "'Upper_Snake'. Expected one of: AS_DECLARED, UPPER_SNAKE_CASE, " + + "SNAKE_CASE, KEBAB_CASE" + ), + ), + result.output, + ) + } + + private fun observed(result: MavenResult): Applied = + Applied( + succeeded = result.succeeded, + appliedPluginCount = result.appliedPluginCount(PLUGIN_NAME), + observations = result.observations(), + ) + + private companion object { + // 利用側が へ書く名前(maven-plugin の + // SealedClassEnumizerMavenPluginExtension.PLUGIN_NAME) + const val PLUGIN_NAME: String = "sealed-class-enumizer" + } +} diff --git a/integration-test/maven-integration/src/test/kotlin/io/github/projectmapk/maven/MavenHarness.kt b/integration-test/maven-integration/src/test/kotlin/io/github/projectmapk/maven/MavenHarness.kt new file mode 100644 index 0000000..9a8c2f1 --- /dev/null +++ b/integration-test/maven-integration/src/test/kotlin/io/github/projectmapk/maven/MavenHarness.kt @@ -0,0 +1,148 @@ +package io.github.projectmapk.maven + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.concurrent.atomic.AtomicInteger +import kotlin.io.path.copyTo +import kotlin.io.path.createDirectories +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.readText +import kotlin.io.path.relativeTo +import kotlin.io.path.writeText + +// Maven フィクスチャの展開と Maven の起動を集約するハーネス(docs/test/フィクスチャ構成.md §6)。 +// フィクスチャは src/test/resources/fixtures/ に置き、テキストのプレースホルダを展開時に置換する: +// - %%ENUMIZER_VERSION%% … 本プラグインのフル版(-<自版>) +// - %%KOTLIN_VERSION%% … フィクスチャが適用する kotlin-maven-plugin の版 +// 版の値は親ビルドを正とし、maven-integration の test タスクが systemProperty で渡す。 +// プラグイン一式は publishToMavenLocal の出力を隔離リポジトリへ複製して解決する +object MavenHarness { + private val mavenHome: Path = Path.of(requiredSystemProperty("enumizer.mavenHome")) + + private val mavenLocalRepo: Path = Path.of(requiredSystemProperty("enumizer.mavenLocalRepo")) + + private val publishedRepo: Path = Path.of(requiredSystemProperty("enumizer.publishedRepo")) + + private val fixtureWorkRoot: Path = Path.of(requiredSystemProperty("enumizer.fixtureWorkRoot")) + + private val enumizerVersion: String = requiredSystemProperty("enumizer.version") + + private val kotlinVersion: String = requiredSystemProperty("enumizer.kotlinVersion") + + private val fixturesRoot: Path = + Path.of( + requireNotNull(javaClass.classLoader.getResource("fixtures")) { + "src/test/resources/fixtures が見つからない" + } + .toURI() + ) + + private val workDirCounter = AtomicInteger() + + private fun requiredSystemProperty(key: String): String = + requireNotNull(System.getProperty(key)) { + "システムプロパティ $key が未設定(maven-integration の test タスクが設定する)" + } + + // フィクスチャ一式を一意な作業ディレクトリへ展開する(1 テスト 1 ディレクトリ) + fun prepareFixture(name: String): Path { + val source = fixturesRoot.resolve(name) + require(source.isDirectory()) { "フィクスチャ $name が存在しない: $source" } + val projectDir = + fixtureWorkRoot.resolve("$name-${workDirCounter.getAndIncrement()}").createDirectories() + Files.walk(source).use { paths -> + paths.forEach { path -> + val target = projectDir.resolve(path.relativeTo(source).toString()) + when { + path.isDirectory() -> target.createDirectories() + isTextFile(path) -> { + target.parent.createDirectories() + target.writeText(expandTextFixture(path.readText())) + } + else -> { + target.parent.createDirectories() + path.copyTo(target, StandardCopyOption.REPLACE_EXISTING) + } + } + } + } + return projectDir + } + + private fun expandTextFixture(text: String): String = + text + .replace("\r\n", "\n") + .replace("%%ENUMIZER_VERSION%%", enumizerVersion) + .replace("%%KOTLIN_VERSION%%", kotlinVersion) + + private fun isTextFile(path: Path): Boolean { + val name = path.fileName.toString() + return name.endsWith(".xml") || name.endsWith(".kt") || name.endsWith(".properties") + } + + // 起動は bin/mvn ではなく classworlds の Launcher を直接叩く(配布物の zip 展開では実行権限が + // 落ちるため、シェルスクリプト経由にするとホストによって起動できない)。 + // JVM はテストと同じもの(toolchain)を使う + fun run(projectDir: Path, vararg arguments: String): MavenResult { + seedLocalRepository() + val command = + listOf( + javaExecutable().toString(), + "-classpath", + classWorldsJar().toString(), + "-Dclassworlds.conf=${mavenHome.resolve("bin").resolve("m2.conf")}", + "-Dmaven.home=$mavenHome", + "-Dmaven.multiModuleProjectDirectory=$projectDir", + "org.codehaus.plexus.classworlds.launcher.Launcher", + // 対話なし・ダウンロード進捗なしで、出力を照合可能な形に保つ + "-B", + "-ntp", + // ローカルリポジトリはテスト専用の場所へ隔離する(docs/test/フィクスチャ構成.md §6)。 + // 既定の ~/.m2/repository を使うと、Maven が取得する Gradle module metadata を伴わない + // 成果物がそこへ入り、mavenLocal() を宣言する TestKit フィクスチャの variant 解決を壊す + "-Dmaven.repo.local=$mavenLocalRepo", + ) + arguments + val process = + ProcessBuilder(command).directory(projectDir.toFile()).redirectErrorStream(true).start() + // 出力を読み切ってから終了を待つ(先に待つとパイプが埋まって停止する) + val output = process.inputStream.bufferedReader().use { it.readText() } + return MavenResult(process.waitFor(), output) + } + + // 隔離リポジトリへ本プラグインの成果物(publishToMavenLocal の出力)だけを複製する。 + // 第三者依存は Maven が隔離先へ取得するため、~/.m2/repository は読むだけで書き換えない。 + // `_remote.repositories` は複製しない(複製先では取得元が一致せず、再解決を要求されるため) + private fun seedLocalRepository() { + val source = publishedRepo.resolve(PUBLISHED_GROUP_PATH) + require(source.isDirectory()) { "publishToMavenLocal の出力が無い: $source(test タスクが前段で公開する)" } + val target = mavenLocalRepo.resolve(PUBLISHED_GROUP_PATH) + Files.walk(source).use { paths -> + paths.forEach { path -> + val destination = target.resolve(path.relativeTo(source).toString()) + when { + path.isDirectory() -> destination.createDirectories() + path.fileName.toString() == "_remote.repositories" -> Unit + else -> { + destination.parent.createDirectories() + path.copyTo(destination, StandardCopyOption.REPLACE_EXISTING) + } + } + } + } + } + + private fun javaExecutable(): Path { + val bin = Path.of(System.getProperty("java.home")).resolve("bin") + val windows = System.getProperty("os.name").startsWith("Windows", ignoreCase = true) + return bin.resolve(if (windows) "java.exe" else "java") + } + + private fun classWorldsJar(): Path = + mavenHome.resolve("boot").listDirectoryEntries("plexus-classworlds-*.jar").singleOrNull() + ?: error("Maven 配布物に plexus-classworlds が 1 つ見つからない: $mavenHome") + + // 複製対象は本プロジェクトの group 配下のみ(フィクスチャの依存指定と同じ座標) + private const val PUBLISHED_GROUP_PATH: String = "io/github/projectmapk" +} diff --git a/integration-test/maven-integration/src/test/kotlin/io/github/projectmapk/maven/MavenResult.kt b/integration-test/maven-integration/src/test/kotlin/io/github/projectmapk/maven/MavenResult.kt new file mode 100644 index 0000000..c2966c9 --- /dev/null +++ b/integration-test/maven-integration/src/test/kotlin/io/github/projectmapk/maven/MavenResult.kt @@ -0,0 +1,19 @@ +package io.github.projectmapk.maven + +// Maven ビルド 1 本の結果。出力は標準出力・標準エラーを合流させたもの +data class MavenResult(val exitCode: Int, val output: String) { + val succeeded: Boolean + get() = exitCode == 0 + + // フィクスチャの JUnit が出す観測行(surefire がテストの標準出力をそのまま流す) + fun observations(): List = + output.lines().map(String::trim).filter { it.startsWith(OBSERVATION_PREFIX) } + + // コンパイレーション毎に 1 回出る mojo のログ。適用範囲(production / test)の観測に使う + fun appliedPluginCount(pluginName: String): Int = + output.lines().count { it.contains("Applied plugin: '$pluginName'") } + + private companion object { + const val OBSERVATION_PREFIX: String = "OUT: " + } +} diff --git a/integration-test/maven-integration/src/test/resources/fixtures/basic/pom.xml b/integration-test/maven-integration/src/test/resources/fixtures/basic/pom.xml new file mode 100644 index 0000000..cbe2c8f --- /dev/null +++ b/integration-test/maven-integration/src/test/resources/fixtures/basic/pom.xml @@ -0,0 +1,125 @@ + + + + 4.0.0 + + io.github.projectmapk.fixtures + maven-basic + 1.0.0 + + + UTF-8 + %%KOTLIN_VERSION%% + %%ENUMIZER_VERSION%% + 17 + + + + + + + org.junit + junit-bom + 5.10.1 + pom + import + + + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + io.github.projectmapk + sealed-class-enumizer-runtime-api-jvm + ${enumizer.version} + + + org.junit.jupiter + junit-jupiter + test + + + + + src/main/kotlin + src/test/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + compile + + + test-compile + test-compile + test-compile + + + + ${maven.compiler.release} + + sealed-class-enumizer + + + + + + io.github.projectmapk + sealed-class-enumizer-maven-plugin + ${enumizer.version} + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + + + + + project-default + + UPPER_SNAKE_CASE + + + + + plugin-option + + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + + + + + + + + + diff --git a/integration-test/maven-integration/src/test/resources/fixtures/basic/src/main/kotlin/io/github/projectmapk/mavenfixture/Pinned.kt b/integration-test/maven-integration/src/test/resources/fixtures/basic/src/main/kotlin/io/github/projectmapk/mavenfixture/Pinned.kt new file mode 100644 index 0000000..5d4b4fd --- /dev/null +++ b/integration-test/maven-integration/src/test/resources/fixtures/basic/src/main/kotlin/io/github/projectmapk/mavenfixture/Pinned.kt @@ -0,0 +1,10 @@ +package io.github.projectmapk.mavenfixture + +import io.github.projectmapk.sealedClassEnumizer.Enumize +import io.github.projectmapk.sealedClassEnumizer.LabelCase + +// 具体指定はプロジェクト既定にも実行単位の指定にも勝つ +@Enumize(labelCase = LabelCase.KEBAB_CASE) +sealed interface Pinned { + data object FooBar : Pinned +} diff --git a/integration-test/maven-integration/src/test/resources/fixtures/basic/src/main/kotlin/io/github/projectmapk/mavenfixture/Si.kt b/integration-test/maven-integration/src/test/resources/fixtures/basic/src/main/kotlin/io/github/projectmapk/mavenfixture/Si.kt new file mode 100644 index 0000000..b3e71a1 --- /dev/null +++ b/integration-test/maven-integration/src/test/resources/fixtures/basic/src/main/kotlin/io/github/projectmapk/mavenfixture/Si.kt @@ -0,0 +1,11 @@ +package io.github.projectmapk.mavenfixture + +import io.github.projectmapk.sealedClassEnumizer.Enumize + +// labelCase 未指定(= PROJECT_DEFAULT)の階層。プロジェクト既定の指定がここへ届く +@Enumize +sealed interface Si { + data object Bar : Si + + data class FooBar(val v: Int) : Si +} diff --git a/integration-test/maven-integration/src/test/resources/fixtures/basic/src/test/kotlin/io/github/projectmapk/mavenfixture/MavenFixtureTest.kt b/integration-test/maven-integration/src/test/resources/fixtures/basic/src/test/kotlin/io/github/projectmapk/mavenfixture/MavenFixtureTest.kt new file mode 100644 index 0000000..f8f65eb --- /dev/null +++ b/integration-test/maven-integration/src/test/resources/fixtures/basic/src/test/kotlin/io/github/projectmapk/mavenfixture/MavenFixtureTest.kt @@ -0,0 +1,16 @@ +package io.github.projectmapk.mavenfixture + +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test + +// 生成 API の実行時観測。label は設定で変わるため OUT 行として出し(照合は maven-integration 側)、 +// 設定に依らない不変(valueOf が entries と同じ kind を返すこと)はここで表明する +class MavenFixtureTest { + @Test + fun observeGeneratedApi() { + println("OUT: si=" + Si.Enumish.entries.joinToString(",") { it.label }) + println("OUT: pinned=" + Pinned.Enumish.entries.joinToString(",") { it.label }) + println("OUT: testOnly=" + TestOnly.Enumish.entries.joinToString(",") { it.label }) + Si.Enumish.entries.forEach { assertSame(it, Si.Enumish.valueOf(it.label)) } + } +} diff --git a/integration-test/maven-integration/src/test/resources/fixtures/basic/src/test/kotlin/io/github/projectmapk/mavenfixture/TestOnly.kt b/integration-test/maven-integration/src/test/resources/fixtures/basic/src/test/kotlin/io/github/projectmapk/mavenfixture/TestOnly.kt new file mode 100644 index 0000000..1da5cae --- /dev/null +++ b/integration-test/maven-integration/src/test/resources/fixtures/basic/src/test/kotlin/io/github/projectmapk/mavenfixture/TestOnly.kt @@ -0,0 +1,9 @@ +package io.github.projectmapk.mavenfixture + +import io.github.projectmapk.sealedClassEnumizer.Enumize + +// test compilation にもプラグインが適用されることの観測用(生成 API を test 側の階層で使う) +@Enumize +sealed interface TestOnly { + data object OnlyLeaf : TestOnly +} diff --git a/integration-test/maven-integration/src/test/resources/fixtures/diag/pom.xml b/integration-test/maven-integration/src/test/resources/fixtures/diag/pom.xml new file mode 100644 index 0000000..2819baf --- /dev/null +++ b/integration-test/maven-integration/src/test/resources/fixtures/diag/pom.xml @@ -0,0 +1,76 @@ + + + + 4.0.0 + + io.github.projectmapk.fixtures + maven-diag + 1.0.0 + + + UTF-8 + %%KOTLIN_VERSION%% + %%ENUMIZER_VERSION%% + 17 + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + io.github.projectmapk + sealed-class-enumizer-runtime-api-jvm + ${enumizer.version} + + + + + src/main/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + compile + + + + ${maven.compiler.release} + + sealed-class-enumizer + + + + + io.github.projectmapk + sealed-class-enumizer-maven-plugin + ${enumizer.version} + + + + + + + + + + bad-label-case + + Upper_Snake + + + + diff --git a/integration-test/maven-integration/src/test/resources/fixtures/diag/src/main/kotlin/io/github/projectmapk/mavenfixture/NotSealed.kt b/integration-test/maven-integration/src/test/resources/fixtures/diag/src/main/kotlin/io/github/projectmapk/mavenfixture/NotSealed.kt new file mode 100644 index 0000000..d951f34 --- /dev/null +++ b/integration-test/maven-integration/src/test/resources/fixtures/diag/src/main/kotlin/io/github/projectmapk/mavenfixture/NotSealed.kt @@ -0,0 +1,6 @@ +package io.github.projectmapk.mavenfixture + +import io.github.projectmapk.sealedClassEnumizer.Enumize + +// ENUMIZE_NOT_SEALED の発火(docs/概要.md §8) +@Enumize class NotSealed diff --git a/integration-test/settings.gradle.kts b/integration-test/settings.gradle.kts index 9853f1e..b68325f 100644 --- a/integration-test/settings.gradle.kts +++ b/integration-test/settings.gradle.kts @@ -42,7 +42,7 @@ rootProject.name = "enumize-integration-test" includeBuild("..") -// docs/test/テスト戦略.md のモジュール一覧(8 モジュール) +// docs/test/テスト戦略.md のモジュール一覧(9 モジュール) include( ":producer-jvm", ":consumer-pure-jvm", @@ -52,4 +52,5 @@ include( ":mpp-producer", ":mpp-consumer", ":gradle-integration", + ":maven-integration", ) diff --git a/maven-plugin/build.gradle.kts b/maven-plugin/build.gradle.kts new file mode 100644 index 0000000..b6b74c4 --- /dev/null +++ b/maven-plugin/build.gradle.kts @@ -0,0 +1,76 @@ +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.KotlinJvm + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.vanniktech.maven.publish) +} + +kotlin { jvmToolchain(17) } + +dependencies { + // 拡張点(KotlinMavenPluginExtension / PluginOption)と Maven のモデル型は実行時に + // kotlin-maven-plugin の realm 側が供給するため、コンパイル時にのみ参照する + compileOnly(libs.kotlin.maven.plugin) + compileOnly(libs.maven.core) + // コンパイラプラグイン本体は POM の依存として配る。kotlin-maven-plugin は自身の を + // 推移解決してコンパイラのプラグインクラスパスへ載せるため、利用側は本成果物 1 つの宣言で足りる + api(project(":sealed-class-enumizer-compiler-plugin")) + testImplementation(libs.kotlin.maven.plugin) + testImplementation(libs.maven.core) + // 受け付ける LabelCase と runtime-api の宣言の一致検証(LabelCaseTest)のため、依存はテストにのみ持つ + testImplementation(project(":sealed-class-enumizer-runtime-api")) + testImplementation(libs.kotlin.test) +} + +tasks.test { useJUnitPlatform() } + +// Maven Central 公開設定(POM の共通値はルート、モジュール別値は本モジュールの gradle.properties)。 +// 本成果物は Mojo ではなく kotlin-maven-plugin の拡張であり、plugin descriptor は持たない +mavenPublishing { + publishToMavenCentral() + signAllPublications() + configure(KotlinJvm(javadocJar = JavadocJar.Empty())) +} + +// SealedClassEnumizerCoordinates はビルドの値(version / Kotlin 版 / コンパイラプラグイン ID)から生成し、 +// ソース側での手動同期を持たない(gradle-plugin と同じ方式) +val coordinatesDir = layout.buildDirectory.dir("generated/sources/coordinates/kotlin") + +val generateCoordinates = + tasks.register("generateCoordinates") { + val version = project.version.toString() + val kotlinVersion = libs.versions.kotlin.get() + val compilerPluginId = providers.gradleProperty("compilerPluginId").get() + val outputDir = coordinatesDir + inputs.property("version", version) + inputs.property("kotlinVersion", kotlinVersion) + inputs.property("compilerPluginId", compilerPluginId) + outputs.dir(outputDir) + doLast { + val file = + outputDir + .get() + .file( + "io/github/projectmapk/sealedClassEnumizer/maven/SealedClassEnumizerCoordinates.kt" + ) + .asFile + file.parentFile.mkdirs() + file.writeText( + """ + |// generateCoordinates タスクが生成するファイル(手動編集しない)。 + |// 値の正はビルド側(version / Kotlin 版 / コンパイラプラグイン ID)にある + |package io.github.projectmapk.sealedClassEnumizer.maven + | + |internal object SealedClassEnumizerCoordinates { + | const val VERSION: String = "$version" + | const val KOTLIN_VERSION: String = "$kotlinVersion" + | const val COMPILER_PLUGIN_ID: String = "$compilerPluginId" + |} + |""" + .trimMargin() + ) + } + } + +kotlin.sourceSets.getByName("main").kotlin.srcDir(generateCoordinates) diff --git a/maven-plugin/gradle.properties b/maven-plugin/gradle.properties new file mode 100644 index 0000000..6d7d1c4 --- /dev/null +++ b/maven-plugin/gradle.properties @@ -0,0 +1,3 @@ +# POM のモジュール別値(共通値はルートの gradle.properties) +POM_NAME=Sealed Class Enumizer Maven Plugin +POM_DESCRIPTION=Maven plugin that applies the sealed-class-enumizer compiler plugin to kotlin-maven-plugin compilations. diff --git a/maven-plugin/src/main/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/LabelCase.kt b/maven-plugin/src/main/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/LabelCase.kt new file mode 100644 index 0000000..f92d0e7 --- /dev/null +++ b/maven-plugin/src/main/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/LabelCase.kt @@ -0,0 +1,27 @@ +package io.github.projectmapk.sealedClassEnumizer.maven + +// runtime-api の LabelCase から PROJECT_DEFAULT を除いた具体ケースと 1:1 対応する。 +// エントリ名の一致は LabelCaseTest がガードし、値はコンパイラプラグインの labelCase オプションへ +// エントリ名のまま渡される +/** + * Label cases accepted as the project-wide default, that is, as the value of the + * `sealed-class-enumizer.labelCase` property described on + * [SealedClassEnumizerMavenPluginExtension]. + * + * Mirrors the runtime API's `LabelCase` without `PROJECT_DEFAULT` — the project default is what + * this property defines, so a "read the project default" entry would be self-referential here. The + * conversion rules are documented on the runtime API's `LabelCase` and are frozen across releases. + */ +enum class LabelCase { + /** No conversion: the label is the declared simple name. */ + AS_DECLARED, + + /** `FooBar` → `FOO_BAR`. */ + UPPER_SNAKE_CASE, + + /** `FooBar` → `foo_bar`. */ + SNAKE_CASE, + + /** `FooBar` → `foo-bar`. */ + KEBAB_CASE, +} diff --git a/maven-plugin/src/main/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/SealedClassEnumizerMavenPluginExtension.kt b/maven-plugin/src/main/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/SealedClassEnumizerMavenPluginExtension.kt new file mode 100644 index 0000000..704c4b5 --- /dev/null +++ b/maven-plugin/src/main/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/SealedClassEnumizerMavenPluginExtension.kt @@ -0,0 +1,163 @@ +package io.github.projectmapk.sealedClassEnumizer.maven + +import org.apache.maven.plugin.MojoExecution +import org.apache.maven.project.MavenProject +import org.codehaus.plexus.logging.Logger +import org.jetbrains.kotlin.maven.KotlinMavenPluginExtension +import org.jetbrains.kotlin.maven.PluginOption + +/** + * Applies the sealed-class-enumizer compiler plugin to the kotlin-maven-plugin compilations that + * list it, and passes the project-wide default label case to it. + * + * Declare the artifact as a dependency of kotlin-maven-plugin and name the plugin in + * `compilerPlugins`; the compiler plugin itself comes along as a transitive dependency: + * ```xml + * + * org.jetbrains.kotlin + * kotlin-maven-plugin + * + * + * sealed-class-enumizer + * + * + * + * + * io.github.projectmapk + * sealed-class-enumizer-maven-plugin + * ${sealed-class-enumizer.version} + * + * + * + * ``` + * + * The configuration above applies to every execution of kotlin-maven-plugin, production and test + * compilations alike. The runtime API is not added automatically: declare + * `io.github.projectmapk:sealed-class-enumizer-runtime-api-jvm` in the project `dependencies`, + * since the generated API exposes its types as supertypes. + * + * The project-wide default label case is read from the `sealed-class-enumizer.labelCase` property + * and defaults to [LabelCase.AS_DECLARED] (no conversion). It applies to hierarchies whose + * `@Enumize` does not specify a concrete `labelCase`: + * ```xml + * + * UPPER_SNAKE_CASE + * + * ``` + * + * The same option can also be given through the kotlin-maven-plugin `pluginOptions` + * (`sealed-class-enumizer:labelCase=UPPER_SNAKE_CASE`). Such a declaration takes precedence: the + * property is then not passed at all, since the compiler option accepts a single value. + */ +class SealedClassEnumizerMavenPluginExtension : KotlinMavenPluginExtension { + // Plexus の requirement 注入(META-INF/plexus/components.xml)で設定される。 + // 用途は対応外 Kotlin の警告のみで、オプションの組み立てには関与しない + lateinit var logger: Logger + + override fun getCompilerPluginId(): String = SealedClassEnumizerCoordinates.COMPILER_PLUGIN_ID + + // goal による絞り込みは行わない(compile / test-compile の双方へ適用する)。 + // 適用範囲は利用側が の記載位置で選ぶ + override fun isApplicable(project: MavenProject, execution: MojoExecution): Boolean = true + + override fun getPluginOptions( + project: MavenProject, + execution: MojoExecution, + ): List { + warnIfUnsupportedKotlin(project, execution) + // 実行単位の直接指定がある場合はプロジェクト既定を渡さない。オプションは複数指定を + // 受け付けず(コンパイラ側が「Multiple values are not allowed」で失敗する)、 + // 直接指定は mojo 自身が渡すため、こちらが降りることで優先関係が成立する + if (declaresLabelCaseOption(execution)) { + return emptyList() + } + // プロジェクト既定の label ケースを常に具体値で渡す(docs/概要.md §4)。 + // キーはコンパイラプラグインの EnumizeCommandLineProcessor.LABEL_CASE_OPTION_NAME と + // 一致していなければならない + return listOf( + PluginOption( + PLUGIN_NAME, + getCompilerPluginId(), + LABEL_CASE_OPTION_NAME, + resolveLabelCase(project).name, + ) + ) + } + + // kotlin-maven-plugin の に本プラグインの labelCase 指定が在るか + // (表記は mojo の解釈と同じ <プラグイン名>:<キー>=<値>) + private fun declaresLabelCaseOption(execution: MojoExecution): Boolean { + val declared = execution.configuration?.getChild(PLUGIN_OPTIONS_ELEMENT) ?: return false + return declared.children.any { + it.value?.trim()?.startsWith("$PLUGIN_NAME:$LABEL_CASE_OPTION_NAME=") == true + } + } + + // 対応外の Kotlin への適用は警告のみとする(サポートは KOTLIN_VERSION と同一マイナー。 + // 非互換の実害はコンパイル時に顕在化するため、原因を示す警告に留めてエラーにはしない) + private fun warnIfUnsupportedKotlin(project: MavenProject, execution: MojoExecution) { + // 適用先の Kotlin 版は、本拡張を読み込んでいる kotlin-maven-plugin 自身の版である + val appliedKotlinVersion = execution.plugin?.version ?: return + if ( + isSupportedKotlinVersion( + appliedKotlinVersion, + SealedClassEnumizerCoordinates.KOTLIN_VERSION, + ) + ) { + return + } + logger.warn(unsupportedKotlinWarning(appliedKotlinVersion, project.artifactId)) + } + + companion object { + // 利用側が へ書く名前であり、Plexus の role-hint(components.xml)と + // 一致していなければならない(PlexusComponentsTest がガードする) + const val PLUGIN_NAME: String = "sealed-class-enumizer" + + /** + * Name of the property holding the project-wide default label case. See + * [SealedClassEnumizerMavenPluginExtension]. + */ + const val LABEL_CASE_PROPERTY: String = "$PLUGIN_NAME.labelCase" + + private const val LABEL_CASE_OPTION_NAME: String = "labelCase" + + // kotlin-maven-plugin の設定要素名(利用者が直接指定を書く場所) + private const val PLUGIN_OPTIONS_ELEMENT: String = "pluginOptions" + } +} + +// プロジェクト既定の label ケース。未指定時の既定はコンパイラプラグインの組み込み既定 +// (EnumizeLabelCase.BUILT_IN_DEFAULT)と同値の AS_DECLARED に固定し、二重既定の乖離を防ぐ。 +// 不正値は診断が読みにくいコンパイラ側の失敗になる前に、設定面の名前を示して弾く +internal fun resolveLabelCase(project: MavenProject): LabelCase { + val declared = + project.properties + .getProperty(SealedClassEnumizerMavenPluginExtension.LABEL_CASE_PROPERTY) + ?.trim() + .orEmpty() + if (declared.isEmpty()) { + return LabelCase.AS_DECLARED + } + return LabelCase.entries.firstOrNull { it.name == declared } + ?: throw IllegalArgumentException( + "Unknown value for the " + + "'${SealedClassEnumizerMavenPluginExtension.LABEL_CASE_PROPERTY}' property: " + + "'$declared'. Expected one of: ${LabelCase.entries.joinToString { it.name }}" + ) +} + +internal fun unsupportedKotlinWarning(appliedKotlinVersion: String, projectName: String): String = + "sealed-class-enumizer ${SealedClassEnumizerCoordinates.VERSION} targets Kotlin " + + "${SealedClassEnumizerCoordinates.KOTLIN_VERSION} and is not verified against Kotlin " + + "$appliedKotlinVersion applied to $projectName. Compilation may fail; use the plugin " + + "version matching your Kotlin version." + +// サポート判定はマイナー一致とする(同一 . ならパッチ・プレリリースの差は対応内。 +// 版形式 -<自版> の下で、成果物は対応マイナーの全パッチへ同一物を配るため) +internal fun isSupportedKotlinVersion( + appliedKotlinVersion: String, + supportedKotlinVersion: String, +): Boolean = majorMinorOf(appliedKotlinVersion) == majorMinorOf(supportedKotlinVersion) + +private fun majorMinorOf(version: String): String = version.split('.').take(2).joinToString(".") diff --git a/maven-plugin/src/main/resources/META-INF/plexus/components.xml b/maven-plugin/src/main/resources/META-INF/plexus/components.xml new file mode 100644 index 0000000..8f48d21 --- /dev/null +++ b/maven-plugin/src/main/resources/META-INF/plexus/components.xml @@ -0,0 +1,23 @@ + + + + + + org.jetbrains.kotlin.maven.KotlinMavenPluginExtension + sealed-class-enumizer + io.github.projectmapk.sealedClassEnumizer.maven.SealedClassEnumizerMavenPluginExtension + false + + + org.codehaus.plexus.logging.Logger + logger + + + + + diff --git a/maven-plugin/src/test/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/KotlinVersionSupportTest.kt b/maven-plugin/src/test/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/KotlinVersionSupportTest.kt new file mode 100644 index 0000000..c08afe4 --- /dev/null +++ b/maven-plugin/src/test/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/KotlinVersionSupportTest.kt @@ -0,0 +1,28 @@ +package io.github.projectmapk.sealedClassEnumizer.maven + +import kotlin.test.Test +import kotlin.test.assertEquals + +// Kotlin バージョンのサポート判定(マイナー一致)の期待値固定。 +// パッチ差・プレリリース表記・マイナー / メジャー差を覆う +class KotlinVersionSupportTest { + private data class Case(val applied: String, val supported: String, val expected: Boolean) + + @Test + fun minorLevelMatchDecidesSupport() { + val cases = + listOf( + Case(applied = "2.4.0", supported = "2.4.0", expected = true), + Case(applied = "2.4.10", supported = "2.4.0", expected = true), + Case(applied = "2.4.20-Beta1", supported = "2.4.0", expected = true), + Case(applied = "2.5.0", supported = "2.4.0", expected = false), + Case(applied = "2.5.0-Beta1", supported = "2.4.20", expected = false), + Case(applied = "2.3.0", supported = "2.4.0", expected = false), + Case(applied = "3.0.0", supported = "2.4.0", expected = false), + ) + assertEquals( + cases.map { it.expected }, + cases.map { isSupportedKotlinVersion(it.applied, it.supported) }, + ) + } +} diff --git a/maven-plugin/src/test/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/LabelCaseTest.kt b/maven-plugin/src/test/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/LabelCaseTest.kt new file mode 100644 index 0000000..dc3b176 --- /dev/null +++ b/maven-plugin/src/test/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/LabelCaseTest.kt @@ -0,0 +1,20 @@ +package io.github.projectmapk.sealedClassEnumizer.maven + +import io.github.projectmapk.sealedClassEnumizer.LabelCase as RuntimeLabelCase +import kotlin.test.Test +import kotlin.test.assertEquals + +// 受け付ける LabelCase は runtime-api の LabelCase から PROJECT_DEFAULT を除いた具体ケースと 1:1 対応する +// (docs/概要.md §4)。宣言はモジュール毎に独立させているため、名前集合と並びの一致をここで担保する: +// runtime-api 側のエントリを追加・改名すると、参照が追随するこのテストが失敗し、更新漏れを検知できる +class LabelCaseTest { + @Test + fun `受け付ける LabelCase は runtime-api の具体ケースと一致する`() { + assertEquals( + RuntimeLabelCase.entries + .filterNot { it == RuntimeLabelCase.PROJECT_DEFAULT } + .map { it.name }, + LabelCase.entries.map { it.name }, + ) + } +} diff --git a/maven-plugin/src/test/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/PlexusComponentsTest.kt b/maven-plugin/src/test/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/PlexusComponentsTest.kt new file mode 100644 index 0000000..e02db85 --- /dev/null +++ b/maven-plugin/src/test/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/PlexusComponentsTest.kt @@ -0,0 +1,87 @@ +package io.github.projectmapk.sealedClassEnumizer.maven + +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import org.codehaus.plexus.logging.Logger +import org.jetbrains.kotlin.maven.KotlinMavenPluginExtension +import org.w3c.dom.Element + +// Plexus コンポーネント登録(META-INF/plexus/components.xml)と実装の対応を固定する。 +// 登録は実行時にしか効かず、名前がずれても本モジュールのコンパイルは通るため、 +// role / role-hint / implementation / requirement のフィールドをここで実体と突き合わせる +class PlexusComponentsTest { + private data class Registration( + val role: String, + val roleHint: String, + val implementation: String, + val requirementRole: String, + val requirementField: String, + // requirement の注入先が実装に在ることの確認(型は宣言された role と一致していなければならない) + val injectedFieldType: String, + val implementsExtensionPoint: Boolean, + ) + + @Test + fun componentDescriptorMatchesImplementation() { + val component = readSingleComponent() + val requirement = + singleChildElement(component, "requirements").let { + singleChildElement(it, "requirement") + } + val implementationClass = Class.forName(text(component, "implementation")) + val injectedField = implementationClass.getDeclaredField(text(requirement, "field-name")) + assertEquals( + Registration( + role = KotlinMavenPluginExtension::class.java.name, + roleHint = SealedClassEnumizerMavenPluginExtension.PLUGIN_NAME, + implementation = SealedClassEnumizerMavenPluginExtension::class.java.name, + requirementRole = Logger::class.java.name, + requirementField = "logger", + injectedFieldType = Logger::class.java.name, + implementsExtensionPoint = true, + ), + Registration( + role = text(component, "role"), + roleHint = text(component, "role-hint"), + implementation = implementationClass.name, + requirementRole = text(requirement, "role"), + requirementField = injectedField.name, + injectedFieldType = injectedField.type.name, + implementsExtensionPoint = + KotlinMavenPluginExtension::class.java.isAssignableFrom(implementationClass), + ), + ) + } + + private fun readSingleComponent(): Element { + val resource = + requireNotNull(javaClass.classLoader.getResource(COMPONENTS_RESOURCE)) { + "$COMPONENTS_RESOURCE がクラスパスに無い" + } + val document = + DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(resource.toString()) + return singleChildElement(document.documentElement, "components").let { + singleChildElement(it, "component") + } + } + + private fun singleChildElement(parent: Element, name: String): Element { + val children = childElements(parent, name) + assertEquals(1, children.size, "$name の要素数") + return children.single() + } + + private fun childElements(parent: Element, name: String): List = + (0 until parent.childNodes.length) + .map(parent.childNodes::item) + .filterIsInstance() + .filter { it.tagName == name } + + private fun text(parent: Element, name: String): String = + singleChildElement(parent, name).textContent.trim() + + private companion object { + const val COMPONENTS_RESOURCE: String = "META-INF/plexus/components.xml" + } +} diff --git a/maven-plugin/src/test/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/SealedClassEnumizerMavenPluginExtensionTest.kt b/maven-plugin/src/test/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/SealedClassEnumizerMavenPluginExtensionTest.kt new file mode 100644 index 0000000..c6b4aa8 --- /dev/null +++ b/maven-plugin/src/test/kotlin/io/github/projectmapk/sealedClassEnumizer/maven/SealedClassEnumizerMavenPluginExtensionTest.kt @@ -0,0 +1,196 @@ +package io.github.projectmapk.sealedClassEnumizer.maven + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.apache.maven.model.Model +import org.apache.maven.model.Plugin +import org.apache.maven.plugin.MojoExecution +import org.apache.maven.project.MavenProject +import org.codehaus.plexus.logging.AbstractLogger +import org.codehaus.plexus.logging.Logger +import org.codehaus.plexus.util.xml.Xpp3Dom + +// kotlin-maven-plugin の拡張点としての振る舞いの固定。 +// 観測はコンパイラへ渡る形(PluginOption.toString = plugin::<キー>=<値>)と、 +// mojo がオプションの振り分けに使う各フィールドで行う +class SealedClassEnumizerMavenPluginExtensionTest { + private data class LabelCaseCase(val declared: String?, val expected: String) + + private data class OptionFields( + val pluginName: String, + val pluginId: String, + val key: String, + val value: String, + ) + + // 警告の発火を観測するための Logger。Plexus は requirement で実装を注入するため、 + // テストでは記録用の実装を差し込む + private class RecordingLogger : AbstractLogger(Logger.LEVEL_INFO, "test") { + val warnings: MutableList = mutableListOf() + + override fun debug(message: String, throwable: Throwable?) = Unit + + override fun info(message: String, throwable: Throwable?) = Unit + + override fun warn(message: String, throwable: Throwable?) { + warnings += message + } + + override fun error(message: String, throwable: Throwable?) = Unit + + override fun fatalError(message: String, throwable: Throwable?) = Unit + + override fun getChildLogger(name: String): Logger = this + } + + @Test + fun labelCaseOptionCarriesTheProjectDefault() { + val cases = + listOf( + // 未指定はコンパイラプラグインの組み込み既定と同値(docs/概要.md §4) + LabelCaseCase(declared = null, expected = "AS_DECLARED"), + LabelCaseCase(declared = "UPPER_SNAKE_CASE", expected = "UPPER_SNAKE_CASE"), + LabelCaseCase(declared = " KEBAB_CASE ", expected = "KEBAB_CASE"), + LabelCaseCase(declared = " ", expected = "AS_DECLARED"), + ) + assertEquals( + cases.map { + "plugin:io.github.projectmapk.sealed-class-enumizer:labelCase=${it.expected}" + }, + cases.map { + extension().getPluginOptions(project(it.declared), execution()).single().toString() + }, + ) + } + + @Test + fun pluginOptionIdentifiesTheCompilerPlugin() { + val option = extension().getPluginOptions(project(), execution()).single() + assertEquals( + OptionFields( + // mojo は の接頭辞(= の名前)でオプションを束ねる + pluginName = "sealed-class-enumizer", + pluginId = "io.github.projectmapk.sealed-class-enumizer", + key = "labelCase", + value = "AS_DECLARED", + ), + OptionFields(option.pluginName, option.pluginId, option.key, option.value), + ) + } + + @Test + fun executionLevelOptionSuppressesTheProjectDefault() { + // 直接指定は mojo 自身が渡す。オプションは複数指定を受け付けないため、こちらは何も渡さない + val declared = + listOf( + "sealed-class-enumizer:labelCase=SNAKE_CASE", + // 他プラグイン向け・他キーの指定は抑止の条件にならない + "all-open:annotation=example.Ann", + "sealed-class-enumizer:other=x", + ) + assertEquals( + listOf(0, 1, 1), + declared.map { + extension() + .getPluginOptions(project("UPPER_SNAKE_CASE"), execution(pluginOptions = it)) + .size + }, + ) + } + + @Test + fun unknownLabelCaseIsRejectedWithTheAcceptedValues() { + val failure = + assertFailsWith { + extension().getPluginOptions(project("Upper_Snake"), execution()) + } + assertEquals( + "Unknown value for the 'sealed-class-enumizer.labelCase' property: 'Upper_Snake'. " + + "Expected one of: AS_DECLARED, UPPER_SNAKE_CASE, SNAKE_CASE, KEBAB_CASE", + failure.message, + ) + } + + @Test + fun appliesToEveryCompilation() { + val goals = listOf("compile", "test-compile") + assertEquals( + goals.map { true }, + goals.map { extension().isApplicable(project(), execution(goal = it)) }, + ) + } + + @Test + fun onlyUnsupportedKotlinMinorIsWarned() { + val logger = RecordingLogger() + val extension = SealedClassEnumizerMavenPluginExtension().apply { this.logger = logger } + listOf("2.9.0", SealedClassEnumizerCoordinates.KOTLIN_VERSION).forEach { + extension.getPluginOptions(project(), execution(kotlinVersion = it)) + } + assertEquals( + listOf( + "sealed-class-enumizer ${SealedClassEnumizerCoordinates.VERSION} targets Kotlin " + + "${SealedClassEnumizerCoordinates.KOTLIN_VERSION} and is not verified against " + + "Kotlin 2.9.0 applied to fixture. Compilation may fail; use the plugin " + + "version matching your Kotlin version." + ), + logger.warnings, + ) + } + + @Test + fun unknownKotlinVersionIsNotWarned() { + // 版が読めない構成では警告しない(Logger 未注入でもオプションの組み立ては成立する) + val options = + SealedClassEnumizerMavenPluginExtension() + .getPluginOptions(project(), execution(kotlinVersion = null)) + assertEquals(1, options.size) + } + + private fun extension(): SealedClassEnumizerMavenPluginExtension = + SealedClassEnumizerMavenPluginExtension().apply { logger = RecordingLogger() } + + private fun project(labelCase: String? = null): MavenProject { + val model = + Model().apply { + groupId = "io.github.projectmapk.fixtures" + artifactId = "fixture" + version = "1.0.0" + } + labelCase?.let { + model.properties.setProperty( + SealedClassEnumizerMavenPluginExtension.LABEL_CASE_PROPERTY, + it, + ) + } + return MavenProject(model) + } + + // 適用先の Kotlin 版は kotlin-maven-plugin 自身の版として渡る。 + // pluginOptions は mojo へ渡る実効設定(プラグイン単位・実行単位の合成後)として観測される + private fun execution( + goal: String = "compile", + kotlinVersion: String? = SealedClassEnumizerCoordinates.KOTLIN_VERSION, + pluginOptions: String? = null, + ): MojoExecution { + val plugin = + Plugin().apply { + groupId = "org.jetbrains.kotlin" + artifactId = "kotlin-maven-plugin" + version = kotlinVersion + } + val execution = MojoExecution(plugin, goal, "default-$goal") + pluginOptions?.let { + execution.configuration = + Xpp3Dom("configuration").apply { + addChild( + Xpp3Dom("pluginOptions").apply { + addChild(Xpp3Dom("option").apply { value = it }) + } + ) + } + } + return execution + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1f8f08e..3649082 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,7 +17,7 @@ dependencyResolutionManagement { } } -include(":runtime-api", ":compiler-plugin", ":gradle-plugin") +include(":runtime-api", ":compiler-plugin", ":gradle-plugin", ":maven-plugin") // 公開 artifactId は「sealed-class-enumizer-」接頭辞付きとする(group 直下に汎用名を置かない)。 // artifactId の源泉であるプロジェクト名ごと揃えることで、KMP のターゲット別 publication の派生名と、 @@ -28,3 +28,5 @@ project(":runtime-api").name = "sealed-class-enumizer-runtime-api" project(":compiler-plugin").name = "sealed-class-enumizer-compiler-plugin" project(":gradle-plugin").name = "sealed-class-enumizer-gradle-plugin" + +project(":maven-plugin").name = "sealed-class-enumizer-maven-plugin"