diff --git a/.github/workflows/gradle-plugin.yml b/.github/workflows/gradle-plugin.yml new file mode 100644 index 00000000..ecd3f0c8 --- /dev/null +++ b/.github/workflows/gradle-plugin.yml @@ -0,0 +1,96 @@ +name: Gradle Plugin + +on: + pull_request: + branches: + - main + paths: + - gradle-plugin/** + - core/** + - .github/workflows/gradle-plugin.yml + push: + branches: + - main + paths: + - gradle-plugin/** + - core/** + - .github/workflows/gradle-plugin.yml + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build & test + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 30 + defaults: + run: + working-directory: gradle-plugin + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + build-root-directory: gradle-plugin + + - name: Build Gradle plugin + run: ./gradlew build + + - name: Publish test results + uses: dorny/test-reporter@v3 + if: always() + with: + name: Gradle Plugin Tests + path: gradle-plugin/build/test-results/test/*.xml + reporter: java-junit + fail-on-error: true + + snapshot: + name: Publish snapshot + if: github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 30 + defaults: + run: + working-directory: gradle-plugin + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + build-root-directory: gradle-plugin + + - name: Stage Gradle plugin snapshot + run: ./gradlew publish + + - name: Deploy snapshot with JReleaser + run: ./gradlew jreleaserDeploy + env: + JRELEASER_GITHUB_TOKEN: ${{ github.token }} + JRELEASER_GPG_PUBLIC_KEY: ${{ secrets.GPG_PUBLIC_KEY }} + JRELEASER_GPG_SECRET_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + JRELEASER_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + JRELEASER_MAVENCENTRAL_SONATYPE_USERNAME: ${{ secrets.NEXUS_USERNAME }} + JRELEASER_MAVENCENTRAL_SONATYPE_PASSWORD: ${{ secrets.NEXUS_PASSWORD }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 881dd84a..b17db1e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,3 +161,43 @@ jobs: JRELEASER_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} JRELEASER_MAVENCENTRAL_RELEASE_DEPLOY_USERNAME: ${{ secrets.NEXUS_USERNAME }} JRELEASER_MAVENCENTRAL_RELEASE_DEPLOY_PASSWORD: ${{ secrets.NEXUS_PASSWORD }} + + publish-gradle-plugin: + name: Publish Gradle plugin + needs: verify + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 30 + defaults: + run: + working-directory: gradle-plugin + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + build-root-directory: gradle-plugin + + - name: Extract version from tag + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV" + + - name: Stage Gradle plugin artifacts + run: ./gradlew publish -Pversion="$VERSION" + + - name: Publish Gradle plugin with JReleaser + run: ./gradlew jreleaserFullRelease -Pversion="$VERSION" + env: + JRELEASER_GITHUB_TOKEN: ${{ github.token }} + JRELEASER_GPG_PUBLIC_KEY: ${{ secrets.GPG_PUBLIC_KEY }} + JRELEASER_GPG_SECRET_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + JRELEASER_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + JRELEASER_MAVENCENTRAL_SONATYPE_USERNAME: ${{ secrets.NEXUS_USERNAME }} + JRELEASER_MAVENCENTRAL_SONATYPE_PASSWORD: ${{ secrets.NEXUS_PASSWORD }} diff --git a/README.md b/README.md index 8714e763..077f1435 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Roseau (/ʁozo/) is a **fast** and **[accurate](ACCURACY.md)** tool for detecting breaking changes between library versions, similar to tools like [japicmp](https://github.com/siom79/japicmp/) or [Revapi](https://github.com/revapi/revapi/). Whether you're a library maintainer or upgrading dependencies in your projects, Roseau helps ensure backward compatibility across versions. -Roseau analyzes both JAR files and source code, is highly configurable, and includes a dedicated Maven plug-in. +Roseau analyzes both JAR files and source code, is highly configurable, and includes dedicated Maven and Gradle plug-ins. The official user documentation is available at [https://alien-tools.github.io/roseau/](https://alien-tools.github.io/roseau/). @@ -15,7 +15,7 @@ The official user documentation is available at [https://alien-tools.github.io/r - Supports Java up to version 25 (including records, sealed types, modules, etc.) - Outputs reports in CSV, HTML, JSON, and Markdown formats - Highly configurable, CLI-first, and scriptable - - Maven plug-in, integration with Gradle + - Dedicated Maven and Gradle plug-ins Like other JAR-based tools, Roseau integrates smoothly into CI pipelines and can analyze artifacts from remote repositories such as Maven Central. Unlike others, Roseau can also analyze source code directly, making it ideal for checking commits, pull requests, or local changes in an IDE, as well as libraries hosted on platforms like GitHub for which compiled JARs are not readily available. @@ -148,7 +148,32 @@ The minimal setup is to bind the `check` goal and provide a baseline: ### In a Gradle build -Gradle builds can run Roseau through the published CLI artifact. A minimal Kotlin DSL setup is: +Roseau provides a dedicated Gradle plug-in (`io.github.alien-tools.roseau`): + +```kotlin +// build.gradle.kts +buildscript { + repositories { mavenCentral() } + dependencies { classpath("io.github.alien-tools:roseau-gradle-plugin:0.7.0") } +} +apply(plugin = "io.github.alien-tools.roseau") + +roseau { + mvnCoord = "com.example:my-library" + v1 = "1.0.0" // baseline version + failOnBreaking = true + + excludes { + annotation("org.apiguardian.api.API") { + arg("status", "INTERNAL") + } + } +} +``` + +Run with `./gradlew roseauCheck`. See the [Gradle guide](https://alien-tools.github.io/roseau/guides/gradle/) for full documentation. + +Alternatively, Gradle builds can also invoke Roseau through the CLI artifact: ```kotlin val roseau by configurations.creating diff --git a/docs/guides/gradle.md b/docs/guides/gradle.md index 0274c564..e3b4d823 100644 --- a/docs/guides/gradle.md +++ b/docs/guides/gradle.md @@ -1,59 +1,198 @@ # Gradle -Roseau does not currently provide a dedicated Gradle plug-in. Gradle builds can still run Roseau by resolving the published CLI artifact and invoking it with a `JavaExec` task. +Roseau provides a dedicated Gradle plugin (`io.github.alien-tools.roseau`) that registers a `roseau` extension and a `roseauCheck` task wired into the `check` lifecycle. -## Minimal setup +## Dedicated plugin -Add a resolvable configuration for the Roseau CLI, then register a verification task: +### Applying the plugin + +Buildscript classpath (Maven Central): + +```kotlin title="build.gradle.kts" +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath("io.github.alien-tools:roseau-gradle-plugin:0.7.0") + } +} + +apply(plugin = "io.github.alien-tools.roseau") +``` + +### Basic usage + +Compare the current project JAR against a published baseline: + +```kotlin title="build.gradle.kts" +roseau { + mvnCoord = "com.example:my-library" + v1 = "1.0.0" // baseline version + // v2 omitted → uses current project JAR + failOnBreaking = true +} +``` + +Run with: + +```bash +./gradlew roseauCheck +``` + +### Comparing two published versions + +```kotlin +roseau { + mvnCoord = "com.example:my-library" + v1 = "1.0.0" + v2 = "2.0.0" + failOnBreaking = true +} +``` + +### Annotation-based exclusions + +Symbols annotated with specific annotations (and optional member values) are excluded from breaking change reports: + +```kotlin +roseau { + mvnCoord = "com.example:my-library" + v1 = "1.0.0" + + excludes { + // Regex patterns for symbol qualified names + names = listOf("com\\.google\\.common\\..*") + + // Simple: any symbol carrying this annotation is ignored + annotation("com.google.common.annotations.Beta") + + // With member-value matching: only ignore symbols where + // @API(status = INTERNAL) + annotation("org.apiguardian.api.API") { + arg("status", "INTERNAL") + } + } +} +``` + +The `arg("status", "INTERNAL")` is automatically converted to the fully-qualified form `org.apiguardian.api.API$Status.INTERNAL` by Roseau's annotation matching. + +### Reports + +Generate reports in multiple formats: + +```kotlin +roseau { + mvnCoord = "com.example:my-library" + v1 = "1.0.0" + reportsDir = layout.buildDirectory.dir("reports/roseau") + + reports { + csv("roseau.csv") + html("roseau.html") + json("roseau.json") + md("CHANGELOG.md") + } +} +``` + +### Extra Maven repositories + +Add custom repositories to resolve baseline artifacts: + +```kotlin +roseau { + mvnCoord = "com.example:my-library" + v1 = "1.0.0" + + mvnRepo { + maven { url = uri("https://internal.repo/maven/") } + mavenLocal() + mavenCentral() + } +} +``` + +### Failure control + +```kotlin +roseau { + failOnBreaking = true // fail on any breaking change + failOnBinaryBreaking = true // fail only on binary-incompatible changes + failOnSourceBreaking = true // fail only on source-incompatible changes + sourceOnly = false // report source-breaking changes only + binaryOnly = false // report binary-breaking changes only +} +``` + +### Full configuration reference + +| Property | Type | Default | Description | +|---|---|---|---| +| `mvnCoord` | `String` | _required_ | `groupId:artifactId` of the library | +| `v1` | `String` | _required_ | Baseline version | +| `v2` | `String` | `project.version` | Target version; omit to use current project JAR | +| `reportsDir` | `DirectoryProperty` | `build/reports/roseau` | Output directory for reports | +| `failOnBreaking` | `Boolean` | `false` | Fail build on any breaking changes | +| `failOnBinaryBreaking` | `Boolean` | `false` | Fail build on binary-incompatible changes | +| `failOnSourceBreaking` | `Boolean` | `false` | Fail build on source-incompatible changes | +| `sourceOnly` | `Boolean` | `false` | Report source-breaking changes only | +| `binaryOnly` | `Boolean` | `false` | Report binary-breaking changes only | +| `classpath` | `List` | `[]` | Additional classpath JARs for v1 and v2 | +| `excludes.names` | `List` | `[]` | Regex patterns for symbol names to exclude | +| `excludes.annotations` | DSL block | – | Annotation-based exclusion entries | +| `mvnRepo` | DSL block | – | Extra Maven repositories | +| `reports` | DSL block | – | Report outputs (csv, html, json, md, cli) | + +--- + +## Without the dedicated plugin (fallback) + +If you prefer not to use the dedicated plugin, Roseau can still be invoked via its CLI artifact with a `JavaExec` task: ```kotlin title="build.gradle.kts" val roseau by configurations.creating dependencies { - roseau("io.github.alien-tools:roseau-cli:0.6.0") + roseau("io.github.alien-tools:roseau-cli:0.7.0") } tasks.register("roseauCheck") { - group = "verification" - description = "Checks API breaking changes with Roseau" - - dependsOn(tasks.named("jar")) - - classpath = roseau - mainClass.set("io.github.alien.roseau.cli.RoseauCLI") - javaLauncher.set( - javaToolchains.launcherFor { - languageVersion.set(JavaLanguageVersion.of(25)) - }, - ) - - doFirst { - val currentJar = tasks.named("jar").get().archiveFile.get().asFile - val reportsDir = layout.buildDirectory.dir("reports/roseau").get().asFile - - args( - "--diff", - "--v1", "com.example:my-library:1.2.3", - "--v2", currentJar.absolutePath, - "--classpath", sourceSets.main.get().compileClasspath.asPath, - "--plain", - "--fail-on-bc", - "--report", "HTML=${reportsDir.resolve("report.html")}", - "--report", "CSV=${reportsDir.resolve("report.csv")}", + group = "verification" + description = "Checks API breaking changes with Roseau" + + dependsOn(tasks.named("jar")) + + classpath = roseau + mainClass.set("io.github.alien.roseau.cli.RoseauCLI") + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(25)) + }, ) - } + + doFirst { + val currentJar = tasks.named("jar").get().archiveFile.get().asFile + val reportsDir = layout.buildDirectory.dir("reports/roseau").get().asFile + + args( + "--diff", + "--v1", "com.example:my-library:1.2.3", + "--v2", currentJar.absolutePath, + "--classpath", sourceSets.main.get().compileClasspath.asPath, + "--plain", + "--fail-on-bc", + "--report", "HTML=${reportsDir.resolve("report.html")}", + "--report", "CSV=${reportsDir.resolve("report.csv")}", + ) + } } tasks.named("check") { - dependsOn("roseauCheck") + dependsOn("roseauCheck") } ``` -Then run: - -```bash -./gradlew roseauCheck -``` - `--v1` is the baseline release, usually the latest released Maven coordinates for the artifact. `--v2` is the JAR produced by the current build. If your project does not use the standard `jar` task for the artifact you publish, replace `jar` with the task that produces that artifact. diff --git a/docs/index.md b/docs/index.md index c9fa0a5c..fb9090b8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ Roseau (/ʁozo/) is a **fast** and **[accurate](https://github.com/alien-tools/roseau/ACCURACY.md)** tool for detecting breaking changes between Java library versions, similar to tools like [japicmp](https://github.com/siom79/japicmp/) or [Revapi](https://github.com/revapi/revapi/). Whether you're a library maintainer or upgrading dependencies in your projects, Roseau helps ensure backward compatibility across versions. -Roseau analyzes both [JAR files and source code](guides/compare.md), is [highly configurable](reference/cli.md), generates [reports](guides/reports.md) in HTML, JSON, and Markdown, and includes a [dedicated Maven plug-in](guides/maven-plugin.md). +Roseau analyzes both [JAR files and source code](guides/compare.md), is [highly configurable](reference/cli.md), generates [reports](guides/reports.md) in HTML, JSON, and Markdown, and includes dedicated [Maven](guides/maven-plugin.md) and [Gradle](guides/gradle.md) plug-ins. [Get the latest release :material-download:](https://github.com/alien-tools/roseau/releases/latest){ .md-button .md-button--primary } [View on GitHub :material-github:](https://github.com/alien-tools/roseau){ .md-button } diff --git a/docs/reference/gradle-plugin.md b/docs/reference/gradle-plugin.md new file mode 100644 index 00000000..67dec738 --- /dev/null +++ b/docs/reference/gradle-plugin.md @@ -0,0 +1,170 @@ +# Gradle Plugin Options + +The Roseau Gradle plugin (`io.github.alien-tools.roseau`) adds a `roseau` extension and a `roseauCheck` task to Java projects. + +## Extension properties + +### `mvnCoord` + +- **Type:** `String` +- **Required** + +Maven `groupId:artifactId` of the library under analysis. + +```kotlin +roseau { + mvnCoord = "com.example:my-library" +} +``` + +### `v1` + +- **Type:** `String` +- **Required** + +Version string of the baseline (old) release. + +### `v2` + +- **Type:** `String` +- **Default:** `project.version.toString()` + +Version string of the target (new) release. When omitted the current project JAR (output of the `jar` task) is used. + +### `version` + +- **Type:** `String` +- **Default:** `project.version.toString()` + +Current project version. Used as a display label when `v2` is omitted. + +### `reportsDir` + +- **Type:** `DirectoryProperty` +- **Default:** `layout.buildDirectory.dir("reports/roseau")` + +Output directory for report files. Relative report paths are resolved against this directory. + +### `failOnBreaking` + +- **Type:** `Boolean` +- **Default:** `false` + +Throws `GradleException` if any breaking change is detected. + +### `failOnBinaryBreaking` + +- **Type:** `Boolean` +- **Default:** `false` + +Throws `GradleException` if any binary-incompatible change is detected. + +### `failOnSourceBreaking` + +- **Type:** `Boolean` +- **Default:** `false` + +Throws `GradleException` if any source-incompatible change is detected. + +### `sourceOnly` + +- **Type:** `Boolean` +- **Default:** `false` + +When `true`, reports only source-breaking changes (filters out binary-only changes). + +### `binaryOnly` + +- **Type:** `Boolean` +- **Default:** `false` + +When `true`, reports only binary-breaking changes (filters out source-only changes). + +### `classpath` + +- **Type:** `List` +- **Default:** `[]` + +Additional classpath entries (JAR paths) shared by both the baseline and target libraries. + +--- + +## `mvnRepo { }` block + +Adds extra Maven repositories used to resolve baseline and target artifacts: + +```kotlin +mvnRepo { + maven { url = uri("https://internal.repo/maven/") } + mavenLocal() + mavenCentral() +} +``` + +Each call registers a repository on the Gradle project. The `roseauCheck` task resolves artifact coordinates against these repositories. + +--- + +## `excludes { }` block + +### `names` + +- **Type:** `List` +- **Default:** `[]` + +Regex patterns matching fully-qualified symbol names to exclude from breaking change reports. + +### `annotation(fqn)` + +Excludes any symbol carrying the named annotation: + +```kotlin +annotation("com.google.common.annotations.Beta") +``` + +### `annotation(fqn) { arg(key, value) }` + +Excludes symbols carrying the named annotation where the specified member-value pairs also match: + +```kotlin +annotation("org.apiguardian.api.API") { + arg("status", "INTERNAL") +} +``` + +This maps to `RoseauOptions.AnnotationExclusion(name, Map.of("status", "INTERNAL"))` in the core API. + +--- + +## `reports { }` block + +Shorthand methods for each report format: + +```kotlin +reports { + csv("roseau.csv") // → BreakingChangesFormatterFactory.CSV + html("roseau.html") // → BreakingChangesFormatterFactory.HTML + json("roseau.json") // → BreakingChangesFormatterFactory.JSON + md("roseau.md") // → BreakingChangesFormatterFactory.MD + cli("roseau.txt") // → BreakingChangesFormatterFactory.CLI (plain) +} +``` + +Relative paths are resolved against `reportsDir`. + +--- + +## Task + +### `roseauCheck` + +- **Type:** `RoseauTask` +- **Group:** `verification` +- **Depends on:** `jar` (for the current project JAR) +- **Lifecycle:** wired into `check` + +The task is cacheable: when baseline coordinates and the current JAR fingerprint stay the same, Gradle skips re-execution. + +```bash +./gradlew roseauCheck +``` diff --git a/gradle-plugin/.gitignore b/gradle-plugin/.gitignore new file mode 100644 index 00000000..eb9611b7 --- /dev/null +++ b/gradle-plugin/.gitignore @@ -0,0 +1,2 @@ +**/build/ +**/.gradle/ diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts new file mode 100644 index 00000000..027cbd7b --- /dev/null +++ b/gradle-plugin/build.gradle.kts @@ -0,0 +1,112 @@ +plugins { + `java-gradle-plugin` + `maven-publish` + id("org.jreleaser") +} + +group = "io.github.alien-tools" +val releaseVersion = findProperty("releaseVersion") as? String +version = releaseVersion ?: "0.7.0-SNAPSHOT" + +repositories { + mavenLocal() + mavenCentral() +} + +dependencies { + implementation("io.github.alien-tools:roseau-core:0.7.0-SNAPSHOT") + + testImplementation(gradleTestKit()) + testImplementation(platform("org.junit:junit-bom:5.11.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("org.assertj:assertj-core:3.27.7") +} + +tasks.withType { + useJUnitPlatform() +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } + withSourcesJar() + withJavadocJar() +} + +tasks.withType().configureEach { + options.release.set(25) +} + +gradlePlugin { + website.set("https://github.com/alien-tools/roseau") + vcsUrl.set("https://github.com/alien-tools/roseau.git") + + plugins { + create("roseauPlugin") { + id = "io.github.alien-tools.roseau" + implementationClass = "io.github.alien.roseau.gradle.RoseauGradlePlugin" + displayName = "Roseau API Compatibility" + description = "Detects binary and source API breaking changes between Java library versions" + tags.set(listOf("api", "compatibility", "breaking-changes", "java", "semver")) + } + } +} + +publishing { + publications { + withType { + pom { + name.set("Roseau Gradle Plugin") + description.set( + "Gradle plugin for Roseau — API breaking change detection for Java libraries") + url.set("https://github.com/alien-tools/roseau") + licenses { + license { + name.set("MIT") + url.set("https://opensource.org/licenses/MIT") + } + } + developers { + developer { + id.set("alien-tools") + name.set("Thomas Degueule") + } + } + scm { + connection.set("scm:git:git://github.com/alien-tools/roseau.git") + developerConnection.set("scm:git:ssh://github.com:alien-tools/roseau.git") + url.set("https://github.com/alien-tools/roseau/tree/main") + } + } + } + } + + repositories { + mavenLocal() + maven { + name = "staging" + url = layout.buildDirectory.dir("staging-deploy") + } + } +} + +jreleaser { + signing { + active = "ALWAYS" + armored = true + } + + deploy { + maven { + mavenCentral { + sonatype { + active = "ALWAYS" + url = "https://central.sonatype.com/api/v1/publisher" + stagingRepository("build/staging-deploy") + } + } + } + } +} diff --git a/gradle-plugin/gradle/wrapper/gradle-wrapper.jar b/gradle-plugin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/gradle-plugin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle-plugin/gradle/wrapper/gradle-wrapper.properties b/gradle-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..5dd3c012 --- /dev/null +++ b/gradle-plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradle-plugin/gradlew b/gradle-plugin/gradlew new file mode 100644 index 00000000..381387f4 --- /dev/null +++ b/gradle-plugin/gradlew @@ -0,0 +1,138 @@ +#!/usr/bin/env sh + +# +# Gradle start up script for POSIX generated by Gradle. +# + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +app_path=$0 +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld -- "$app_path" ) + link=${ls#*' -> '} + case $link in + /*) app_path=$link ;; + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in + CYGWIN* ) cygwin=true ;; + Darwin* ) darwin=true ;; + MSYS* | MINGW* ) msys=true ;; + NonStop* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 ; then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in + '' | soft) :;; + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stracks://the://following://order:// +# 1) -Dorg.gradle.appname so that the Gradle distribution can be identified +# 2) the Gradle start-up class +# 3) the remaining arguments + +# Use "xargs" to parse quoted args. +# +# With -d, we can split the arguments by newline instead of space. +# This handles arguments with spaces correctly. + +# Collect all arguments for the java command: +# $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +# -Dorg.gradle.appname=$APP_BASE_NAME +# -classpath $CLASSPATH +# org.gradle.wrapper.GradleWrapperMain +# "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 ; then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" \ + $DEFAULT_JVM_OPTS \ + $JAVA_OPTS \ + $GRADLE_OPTS \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" diff --git a/gradle-plugin/gradlew.bat b/gradle-plugin/gradlew.bat new file mode 100644 index 00000000..93a0c41f --- /dev/null +++ b/gradle-plugin/gradlew.bat @@ -0,0 +1,25 @@ +@rem Gradle startup script for Windows + +@if "%DEBUG%"=="" @echo off +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_HOME%/bin/java.exe" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/gradle-plugin/settings.gradle.kts b/gradle-plugin/settings.gradle.kts new file mode 100644 index 00000000..3fb5afeb --- /dev/null +++ b/gradle-plugin/settings.gradle.kts @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } + plugins { + id("org.jreleaser") version "1.19.0" + } +} + +rootProject.name = "roseau-gradle-plugin" diff --git a/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/RoseauExtension.java b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/RoseauExtension.java new file mode 100644 index 00000000..73a6d8f7 --- /dev/null +++ b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/RoseauExtension.java @@ -0,0 +1,209 @@ +package io.github.alien.roseau.gradle; + +import io.github.alien.roseau.gradle.dsl.ExcludeExtension; +import io.github.alien.roseau.gradle.dsl.MavenRepositoryExtension; +import io.github.alien.roseau.gradle.dsl.ReportSpec; +import org.gradle.api.Action; +import org.gradle.api.Project; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.model.ObjectFactory; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Nested; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.OutputDirectory; + +import javax.inject.Inject; +import java.util.ArrayList; + +/** + * Root DSL extension registered under the {@code roseau} name. + * + *
{@code
+ * roseau {
+ *     mvnCoord = "com.example:my-library"
+ *     v1 = "1.0.0"
+ *     // v2 defaults to the current project's JAR
+ *
+ *     failOnBreaking = true
+ *     reportsDir = layout.buildDirectory.dir("reports/roseau")
+ *
+ *     excludes {
+ *         names = ["com\\.google\\.common\\..*"]
+ *         annotation("com.google.common.annotations.Beta")
+ *         annotation("org.apiguardian.api.API") {
+ *             arg("status", "INTERNAL")
+ *         }
+ *     }
+ *
+ *     reports {
+ *         csv("roseau.csv")
+ *         html("roseau.html")
+ *     }
+ * }
+ * }
+ */ +public abstract class RoseauExtension { + + private final Project project; + private final ObjectFactory objects; + + // -- Artifact identification -- + + /** Current project/library version. Defaults to {@code project.version.toString()}. */ + @Input + public abstract Property getVersion(); + + /** Maven {@code groupId:artifactId} of the library under analysis. */ + @Input + public abstract Property getMvnCoord(); + + /** Baseline version string. */ + @Input + public abstract Property getV1(); + + /** + * Target version string. When omitted, the current project JAR + * (the {@code jar} task output) is used. + */ + @Input + @Optional + public abstract Property getV2(); + + // -- Directories -- + + /** Reports output directory. Defaults to {@code build/reports/roseau}. */ + @OutputDirectory + public abstract DirectoryProperty getReportsDir(); + + // -- Failure behaviour -- + + /** Fail the build when any breaking change is detected. */ + @Input + public abstract Property getFailOnBreaking(); + + /** Fail the build when binary-incompatible changes are found. */ + @Input + public abstract Property getFailOnBinaryBreaking(); + + /** Fail the build when source-incompatible changes are found. */ + @Input + public abstract Property getFailOnSourceBreaking(); + + // -- Diff filtering -- + + /** Report source-breaking changes only. */ + @Input + public abstract Property getSourceOnly(); + + /** Report binary-breaking changes only. */ + @Input + public abstract Property getBinaryOnly(); + + // -- Classpath -- + + /** Additional classpath JAR paths shared by baseline and target. */ + @Input + @Optional + public abstract ListProperty getClasspath(); + + // -- Nested extensions -- + + /** Extra Maven repositories for artifact resolution. */ + @Nested + public abstract Property getMvnRepoExtension(); + + /** Name and annotation exclusions. */ + @Nested + @Optional + public abstract Property getExcludes(); + + /** Configured report outputs. */ + @Nested + @Optional + public abstract ListProperty getReports(); + + // -- Project handle (not an input, just a back-reference) -- + + @Internal + public Project getProject() { + return project; + } + + @Inject + public RoseauExtension(Project project) { + this.project = project; + this.objects = project.getObjects(); + + getVersion().convention(project.getVersion().toString()); + getFailOnBreaking().convention(false); + getFailOnBinaryBreaking().convention(false); + getFailOnSourceBreaking().convention(false); + getSourceOnly().convention(false); + getBinaryOnly().convention(false); + getClasspath().convention(new ArrayList<>()); + getReportsDir().convention( + project.getLayout().getBuildDirectory().dir("reports/roseau")); + getMvnRepoExtension().convention( + objects.newInstance(MavenRepositoryExtension.class)); + } + + // -- DSL block methods -- + + /** Configures extra Maven repositories. */ + public void mvnRepo(Action action) { + action.execute(getMvnRepoExtension().get()); + } + + /** Configures exclusions (name regexes + annotations). */ + public void excludes(Action action) { + ExcludeExtension ext = getExcludes().getOrElse( + objects.newInstance(ExcludeExtension.class)); + action.execute(ext); + getExcludes().set(ext); + } + + /** Configures report outputs. */ + public void reports(Action action) { + ReportDsl dsl = new ReportDsl(project, objects, getReports()); + action.execute(dsl); + getReports().set(dsl.collect()); + } + + /** + * Internal DSL helper that exposes shorthand methods for each report format. + */ + public static class ReportDsl { + private final Project project; + private final ObjectFactory objects; + private final java.util.List specs = new ArrayList<>(); + + ReportDsl(Project project, ObjectFactory objects, ListProperty existing) { + this.project = project; + this.objects = objects; + if (existing.isPresent()) { + this.specs.addAll(existing.get()); + } + } + + public void csv(String path) { add(path, "CSV"); } + public void html(String path) { add(path, "HTML"); } + public void json(String path) { add(path, "JSON"); } + public void md(String path) { add(path, "MD"); } + public void cli(String path) { add(path, "CLI"); } + + private void add(String path, String format) { + ReportSpec spec = objects.newInstance(ReportSpec.class); + spec.getFile().set( + project.getLayout().getProjectDirectory().file(path)); + spec.getFormat().set(format); + specs.add(spec); + } + + java.util.List collect() { + return java.util.List.copyOf(specs); + } + } +} diff --git a/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/RoseauGradlePlugin.java b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/RoseauGradlePlugin.java new file mode 100644 index 00000000..5a5cea4b --- /dev/null +++ b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/RoseauGradlePlugin.java @@ -0,0 +1,52 @@ +package io.github.alien.roseau.gradle; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.plugins.JavaPlugin; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.api.tasks.bundling.Jar; + +/** + * Entry point for the Roseau Gradle plugin. + * + *

Plugin ID: {@code io.github.alien-tools.roseau} + * + *

Registers the {@code roseau} extension and the {@code roseauCheck} task + * on every project that applies the Java plugin. The task is wired into the + * {@code check} lifecycle. + */ +public final class RoseauGradlePlugin implements Plugin { + + /** Extension name for the {@code roseau { … }} block. */ + static final String EXTENSION_NAME = "roseau"; + + /** Task name registered by this plugin. */ + static final String TASK_NAME = "roseauCheck"; + + @Override + public void apply(Project project) { + project.getPlugins().withType(JavaPlugin.class, javaPlugin -> { + // Register the DSL extension + RoseauExtension extension = project.getExtensions() + .create(EXTENSION_NAME, RoseauExtension.class, project); + + // Register the task + TaskProvider task = project.getTasks() + .register(TASK_NAME, RoseauTask.class, t -> { + t.setGroup("verification"); + t.setDescription( + "Analyzes API breaking changes between two library versions"); + t.getExtension().set(extension); + + // Wire the current project's JAR as an input + t.getCurrentJar().set( + project.getTasks().named("jar", Jar.class) + .flatMap(jarTask -> jarTask.getArchiveFile())); + }); + + // Wire into the `check` lifecycle + project.getTasks().named("check").configure( + check -> check.dependsOn(task)); + }); + } +} diff --git a/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/RoseauTask.java b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/RoseauTask.java new file mode 100644 index 00000000..df8d7306 --- /dev/null +++ b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/RoseauTask.java @@ -0,0 +1,240 @@ +package io.github.alien.roseau.gradle; + +import io.github.alien.roseau.Library; +import io.github.alien.roseau.Roseau; +import io.github.alien.roseau.diff.RoseauReport; +import io.github.alien.roseau.diff.changes.BreakingChange; +import io.github.alien.roseau.diff.formatter.BreakingChangesFormatterFactory; +import io.github.alien.roseau.diff.formatter.CliFormatter; +import io.github.alien.roseau.gradle.dsl.AnnotationExclusionSpec; +import io.github.alien.roseau.gradle.dsl.ExcludeExtension; +import io.github.alien.roseau.gradle.dsl.ReportSpec; +import io.github.alien.roseau.options.RoseauOptions; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.CacheableTask; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.Nested; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Resolves library artifacts, runs the Roseau diff engine, writes reports, + * and fails the build when configured to do so. + * + *

This task is cacheable — when baseline coordinates and the current JAR + * fingerprint stay the same, Gradle skips re-execution. + */ +@CacheableTask +public abstract class RoseauTask extends DefaultTask { + + // -- Extension handle -- + + @Nested + public abstract Property getExtension(); + + // -- Current project JAR -- + + @InputFile + @PathSensitive(PathSensitivity.RELATIVE) + public abstract RegularFileProperty getCurrentJar(); + + // -- Action -- + + @TaskAction + public void execute() { + RoseauExtension ext = getExtension().get(); + + // -- Parse coordinates -- + String[] coord = parseMvnCoord(ext.getMvnCoord().get()); + String groupId = coord[0]; + String artifactId = coord[1]; + + // -- Resolve baseline (v1) -- + Path v1Jar = resolveArtifact(groupId, artifactId, ext.getV1().get()); + + // -- Resolve or locate target (v2) -- + Path v2Jar; + String v2Version; + if (ext.getV2().isPresent()) { + v2Version = ext.getV2().get(); + v2Jar = resolveArtifact(groupId, artifactId, v2Version); + } else { + v2Version = ext.getVersion().get(); + v2Jar = getCurrentJar().get().getAsFile().toPath(); + } + + getLogger().lifecycle("Roseau: comparing {}:{}:{} vs {}:{}:{}", + groupId, artifactId, ext.getV1().get(), + groupId, artifactId, v2Version); + + // -- Exclusions -- + RoseauOptions.Exclude exclude = buildExclusions(ext); + + // -- Classpath -- + List classpath = ext.getClasspath().get().stream() + .map(Path::of) + .filter(Files::exists) + .toList(); + + // -- Libraries -- + Library v1Library = Library.builder() + .location(v1Jar) + .classpath(classpath) + .exclusions(exclude) + .build(); + + Library v2Library = Library.builder() + .location(v2Jar) + .classpath(classpath) + .exclusions(exclude) + .build(); + + // -- Diff -- + RoseauReport report = Roseau.diff(v1Library, v2Library); + + // -- Filter -- + RoseauOptions.Diff diffOptions = new RoseauOptions.Diff( + null, + ext.getSourceOnly().get(), + ext.getBinaryOnly().get()); + RoseauReport filtered = report.filterReport(diffOptions); + + // -- Reports -- + Path reportsDir = ext.getReportsDir().get().getAsFile().toPath(); + writeReports(filtered, ext, reportsDir); + + // -- Console summary -- + List bcs = filtered.getBreakingChanges(); + if (bcs.isEmpty()) { + getLogger().lifecycle("Roseau: no breaking changes found between {}:{}:{} and {}:{}:{}.", + groupId, artifactId, ext.getV1().get(), groupId, artifactId, v2Version); + } else { + CliFormatter formatter = new CliFormatter(CliFormatter.Mode.PLAIN); + getLogger().warn(formatter.format(filtered)); + } + + // -- Fail checks -- + checkFailures(filtered, ext); + } + + // -- Private helpers -- + + private static String[] parseMvnCoord(String coord) { + String[] parts = coord.split(":"); + if (parts.length != 2) { + throw new GradleException( + "roseau.mvnCoord must be 'groupId:artifactId', got: " + coord); + } + return parts; + } + + private Path resolveArtifact(String groupId, String artifactId, String version) { + // Allow optional classifier: "1.0.0:sources" + String[] parts = version.split(":", 2); + String ver = parts[0]; + String classifier = parts.length > 1 ? parts[1] : null; + + String notation = groupId + ":" + artifactId + ":" + ver; + if (classifier != null) { + notation = notation + ":" + classifier; + } + + Configuration config = getProject().getConfigurations() + .detachedConfiguration( + getProject().getDependencies().create(notation)); + config.setTransitive(false); + config.setDescription("Roseau artifact resolution: " + notation); + + Set files = config.resolve(); + if (files.isEmpty()) { + throw new GradleException( + "Roseau: failed to resolve artifact " + notation); + } + + File jar = files.stream() + .filter(f -> f.getName().endsWith(".jar")) + .findFirst() + .orElseGet(() -> files.iterator().next()); + + return jar.toPath(); + } + + private static RoseauOptions.Exclude buildExclusions(RoseauExtension ext) { + ExcludeExtension excl = ext.getExcludes().getOrNull(); + if (excl == null) { + return new RoseauOptions.Exclude(List.of(), List.of()); + } + + List names = excl.getNames().getOrElse(List.of()); + List annotations = new ArrayList<>(); + + for (AnnotationExclusionSpec spec : excl.getAnnotations().getOrElse(List.of())) { + annotations.add(new RoseauOptions.AnnotationExclusion( + spec.getName().get(), + spec.getArgs().getOrElse(Map.of()))); + } + + return new RoseauOptions.Exclude(names, annotations); + } + + private void writeReports(RoseauReport report, RoseauExtension ext, Path reportsDir) { + if (!ext.getReports().isPresent()) { + return; + } + + for (ReportSpec spec : ext.getReports().get()) { + String format = spec.getFormat().get(); + String fileName = spec.getFile().get().getAsFile().getName(); + Path outputPath = reportsDir.resolve(fileName); + + try { + Files.createDirectories(outputPath.getParent()); + BreakingChangesFormatterFactory fmt = + BreakingChangesFormatterFactory.valueOf(format.toUpperCase(Locale.ROOT)); + report.writeReport(fmt, outputPath); + getLogger().lifecycle("Roseau: {} report written to {}", format, outputPath); + } catch (IOException e) { + throw new GradleException( + "Roseau: failed to write " + format + " report to " + outputPath, e); + } + } + } + + private void checkFailures(RoseauReport report, RoseauExtension ext) { + List bcs = report.getBreakingChanges(); + if (ext.getFailOnBreaking().get() && !bcs.isEmpty()) { + throw new GradleException( + "Roseau: " + bcs.size() + + " breaking change(s) found — build failed (failOnBreaking=true)"); + } + if (ext.getFailOnBinaryBreaking().get() && report.isBinaryBreaking()) { + throw new GradleException( + "Roseau: binary-incompatible change(s) found — build failed " + + "(failOnBinaryBreaking=true)"); + } + if (ext.getFailOnSourceBreaking().get() && report.isSourceBreaking()) { + throw new GradleException( + "Roseau: source-incompatible change(s) found — build failed " + + "(failOnSourceBreaking=true)"); + } + } +} diff --git a/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/dsl/AnnotationExclusionSpec.java b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/dsl/AnnotationExclusionSpec.java new file mode 100644 index 00000000..81a67bb9 --- /dev/null +++ b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/dsl/AnnotationExclusionSpec.java @@ -0,0 +1,35 @@ +package io.github.alien.roseau.gradle.dsl; + +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; + +/** + * Gradle model for a single annotation-based exclusion entry. + * + *

Maps to {@code io.github.alien.roseau.options.RoseauOptions.AnnotationExclusion}. + * + *

{@code
+ * annotation("org.apiguardian.api.API") {
+ *     arg("status", "INTERNAL")
+ * }
+ * }
+ */ +public abstract class AnnotationExclusionSpec { + + /** Fully-qualified annotation name, e.g. {@code org.apiguardian.api.API}. */ + @Input + public abstract Property getName(); + + /** + * Annotation member-value pairs that must match for the exclusion to take effect. + * When empty, any symbol carrying the annotation is excluded regardless of its values. + */ + @Input + public abstract MapProperty getArgs(); + + /** Adds a member-value pair. */ + public void arg(String key, String value) { + getArgs().put(key, value); + } +} diff --git a/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/dsl/ExcludeExtension.java b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/dsl/ExcludeExtension.java new file mode 100644 index 00000000..e4763b1a --- /dev/null +++ b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/dsl/ExcludeExtension.java @@ -0,0 +1,70 @@ +package io.github.alien.roseau.gradle.dsl; + +import org.gradle.api.Action; +import org.gradle.api.model.ObjectFactory; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.tasks.Input; + +import javax.inject.Inject; +import java.util.ArrayList; + +/** + * Manages name-based and annotation-based exclusions. + * + *
{@code
+ * excludes {
+ *     names = ["com\\.google\\.common\\..*"]
+ *     annotation("com.google.common.annotations.Beta")
+ *     annotation("org.apiguardian.api.API") {
+ *         arg("status", "INTERNAL")
+ *     }
+ * }
+ * }
+ */ +public abstract class ExcludeExtension { + + private final ObjectFactory objects; + + /** Regex patterns matching fully-qualified symbol names to exclude. */ + @Input + public abstract ListProperty getNames(); + + /** Annotation-based exclusion entries. */ + @Input + public abstract ListProperty getAnnotations(); + + @Inject + public ExcludeExtension(ObjectFactory objects) { + this.objects = objects; + getNames().convention(new ArrayList<>()); + getAnnotations().convention(new ArrayList<>()); + } + + /** + * Registers a simple annotation exclusion (no value checks — any symbol + * carrying the annotation is excluded). + * + *
{@code annotation("com.google.common.annotations.Beta")}
+ */ + public void annotation(String fqn) { + AnnotationExclusionSpec spec = objects.newInstance(AnnotationExclusionSpec.class); + spec.getName().set(fqn); + getAnnotations().add(spec); + } + + /** + * Registers an annotation exclusion with member-value matching. + * + *
{@code
+     * annotation("org.apiguardian.api.API") {
+     *     arg("status", "INTERNAL")
+     * }
+     * }
+ */ + public void annotation(String fqn, Action action) { + AnnotationExclusionSpec spec = objects.newInstance(AnnotationExclusionSpec.class); + spec.getName().set(fqn); + action.execute(spec); + getAnnotations().add(spec); + } +} diff --git a/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/dsl/MavenRepositoryExtension.java b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/dsl/MavenRepositoryExtension.java new file mode 100644 index 00000000..99e8a062 --- /dev/null +++ b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/dsl/MavenRepositoryExtension.java @@ -0,0 +1,57 @@ +package io.github.alien.roseau.gradle.dsl; + +import org.gradle.api.Action; +import org.gradle.api.Project; +import org.gradle.api.artifacts.repositories.MavenArtifactRepository; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.tasks.Internal; + +import javax.inject.Inject; +import java.util.ArrayList; + +/** + * Collects extra Maven repository definitions for resolving baseline/target artifacts. + * Each call to {@code maven}, {@code mavenLocal}, or {@code mavenCentral} adds a + * repository to the owning {@link Project} so that detached configurations used by + * {@link io.github.alien.roseau.gradle.RoseauTask} can resolve artifacts against it. + * + *
{@code
+ * mvnRepo {
+ *     maven { url = "https://internal.repo/maven/" }
+ *     mavenLocal()
+ *     mavenCentral()
+ * }
+ * }
+ */ +public abstract class MavenRepositoryExtension { + + private final Project project; + + /** Repository URLs for debug/tracking purposes. */ + @Internal + public abstract ListProperty getRepositoryUrls(); + + @Inject + public MavenRepositoryExtension(Project project) { + this.project = project; + getRepositoryUrls().convention(new ArrayList<>()); + } + + /** Adds a custom Maven repository. */ + public void maven(Action action) { + MavenArtifactRepository repo = project.getRepositories().maven(action); + getRepositoryUrls().add(repo.getUrl().toString()); + } + + /** Adds the local Maven repository ({@code ~/.m2/repository}). */ + public void mavenLocal() { + project.getRepositories().mavenLocal(); + getRepositoryUrls().add("mavenLocal()"); + } + + /** Ensures Maven Central is available for artifact resolution. */ + public void mavenCentral() { + project.getRepositories().mavenCentral(); + getRepositoryUrls().add("mavenCentral()"); + } +} diff --git a/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/dsl/ReportSpec.java b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/dsl/ReportSpec.java new file mode 100644 index 00000000..5c7b523c --- /dev/null +++ b/gradle-plugin/src/main/java/io/github/alien/roseau/gradle/dsl/ReportSpec.java @@ -0,0 +1,23 @@ +package io.github.alien.roseau.gradle.dsl; + +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Internal; + +/** + * A single report output (format + destination file). + * + *

The format String is mapped to a + * {@code io.github.alien.roseau.diff.formatter.BreakingChangesFormatterFactory} value + * at execution time. + */ +public abstract class ReportSpec { + + /** Output file. Relative paths are resolved against {@code reportsDir}. */ + @Internal + public abstract RegularFileProperty getFile(); + + /** Report format: one of {@code CLI}, {@code CSV}, {@code HTML}, {@code JSON}, {@code MD}. */ + @Internal + public abstract Property getFormat(); +} diff --git a/gradle-plugin/src/test/java/io/github/alien/roseau/gradle/RoseauPluginFunctionalTest.java b/gradle-plugin/src/test/java/io/github/alien/roseau/gradle/RoseauPluginFunctionalTest.java new file mode 100644 index 00000000..a049ad6b --- /dev/null +++ b/gradle-plugin/src/test/java/io/github/alien/roseau/gradle/RoseauPluginFunctionalTest.java @@ -0,0 +1,330 @@ +package io.github.alien.roseau.gradle; + +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.gradle.testkit.runner.TaskOutcome; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Functional tests for the Roseau Gradle plugin. + *

+ * Builds a "v1" baseline JAR, then runs the plugin against a project + * whose source has breaking changes relative to that baseline. + */ +class RoseauPluginFunctionalTest { + + @TempDir + Path testProjectDir; + + private Path v1Repo; + + @BeforeEach + void setUp() throws Exception { + // --- Build the v1 (baseline) JAR --- + Path v1Src = testProjectDir.resolve("v1-src/pkg"); + Files.createDirectories(v1Src); + + Files.writeString(v1Src.resolve("Hello.java"), """ + package pkg; + public class Hello { + public void greet() { System.out.println("hello"); } + /** @deprecated removed in v2 */ + public void legacy() { } + public String getName() { return "Hello"; } + } + """); + + Files.writeString(v1Src.resolve("Util.java"), """ + package pkg; + public class Util { + public static int add(int a, int b) { return a + b; } + } + """); + + Path v1Classes = testProjectDir.resolve("v1-classes"); + Files.createDirectories(v1Classes); + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + int rc = compiler.run(null, null, null, + "-d", v1Classes.toString(), + v1Src.resolve("Hello.java").toString(), + v1Src.resolve("Util.java").toString()); + assertThat(rc).isZero(); + + Path v1Jar = testProjectDir.resolve("v1.jar"); + try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(v1Jar))) { + addDirToJar(v1Classes, v1Classes, jos); + } + + // Install to file-based Maven repo + v1Repo = testProjectDir.resolve("maven-repo"); + Path artifactDir = v1Repo.resolve("test/v1-lib/1.0.0"); + Files.createDirectories(artifactDir); + Files.copy(v1Jar, artifactDir.resolve("v1-lib-1.0.0.jar")); + Files.writeString(artifactDir.resolve("v1-lib-1.0.0.pom"), """ + + 4.0.0 + test + v1-lib + 1.0.0 + + """); + + // --- Write the test project (v2) source — has breaking changes --- + Path src = testProjectDir.resolve("src/main/java/pkg"); + Files.createDirectories(src); + + // v2: removed legacy(), changed getName return type + Files.writeString(src.resolve("Hello.java"), """ + package pkg; + public class Hello { + public void greet() { System.out.println("hello v2"); } + public CharSequence getName() { return "Hello v2"; } + } + """); + + Files.writeString(src.resolve("Util.java"), """ + package pkg; + public class Util { + public static int add(int a, int b) { return a + b; } + } + """); + + Files.writeString(testProjectDir.resolve("settings.gradle.kts"), """ + rootProject.name = "roseau-test" + """); + } + + // -- Tests -- + + @Test + void shouldDetectBreakingChangeAndFailBuild() throws IOException { + Files.writeString(testProjectDir.resolve("build.gradle.kts"), """ + plugins { + id("java") + id("io.github.alien-tools.roseau") + } + + roseau { + mvnCoord = "test:v1-lib" + v1 = "1.0.0" + failOnBreaking = true + + mvnRepo { + maven { url = uri("%s") } + } + + reports { + csv("roseau.csv") + } + } + """.formatted(v1Repo.toUri().toString())); + + BuildResult result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments("roseauCheck") + .withPluginClasspath() + .forwardOutput() + .buildAndFail(); + + assertThat(result.task(":roseauCheck").getOutcome()) + .isEqualTo(TaskOutcome.FAILED); + assertThat(result.getOutput()) + .contains("EXECUTABLE_REMOVED"); + assertThat(result.getOutput()) + .contains("Breaking Changes found"); + } + + @Test + void shouldWriteReports() throws IOException { + Files.writeString(testProjectDir.resolve("build.gradle.kts"), """ + plugins { + id("java") + id("io.github.alien-tools.roseau") + } + + roseau { + mvnCoord = "test:v1-lib" + v1 = "1.0.0" + failOnBreaking = false + reportsDir = layout.buildDirectory.dir("reports/roseau") + + mvnRepo { + maven { url = uri("%s") } + } + + reports { + csv("roseau.csv") + html("roseau.html") + json("roseau.json") + md("roseau.md") + } + } + """.formatted(v1Repo.toUri().toString())); + + GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments("roseauCheck") + .withPluginClasspath() + .forwardOutput() + .build(); + + Path reportsDir = testProjectDir.resolve("build/reports/roseau"); + assertThat(reportsDir.resolve("roseau.csv")).exists(); + assertThat(reportsDir.resolve("roseau.html")).exists(); + assertThat(reportsDir.resolve("roseau.json")).exists(); + assertThat(reportsDir.resolve("roseau.md")).exists(); + + String csv = Files.readString(reportsDir.resolve("roseau.csv")); + assertThat(csv).contains("pkg.Hello"); + assertThat(csv).contains("EXECUTABLE_REMOVED"); + } + + @Test + void shouldExcludeByAnnotation() throws Exception { + // Re-create v1 where Hello is annotated @InternalApi + Path internalDir = testProjectDir.resolve("v1-src2/internal"); + Path pkgDir = testProjectDir.resolve("v1-src2/pkg"); + Files.createDirectories(internalDir); + Files.createDirectories(pkgDir); + + Files.writeString(internalDir.resolve("InternalApi.java"), """ + package internal; + public @interface InternalApi {} + """); + + Files.writeString(pkgDir.resolve("Hello.java"), """ + package pkg; + import internal.InternalApi; + @InternalApi + public class Hello { + public void greet() { } + public void legacy() { } + public String getName() { return "hello"; } + } + """); + + Files.writeString(pkgDir.resolve("Util.java"), """ + package pkg; + public class Util { + public static int add(int a, int b) { return a + b; } + } + """); + + Path v1Classes2 = testProjectDir.resolve("v1-classes2"); + Files.createDirectories(v1Classes2); + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + int rc = compiler.run(null, null, null, + "-d", v1Classes2.toString(), + internalDir.resolve("InternalApi.java").toString(), + pkgDir.resolve("Hello.java").toString(), + pkgDir.resolve("Util.java").toString()); + assertThat(rc).isZero(); + + Path v1Jar2 = testProjectDir.resolve("v1-2.jar"); + try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(v1Jar2))) { + addDirToJar(v1Classes2, v1Classes2, jos); + } + + Path artifactDir = v1Repo.resolve("test/v1-lib/1.0.1"); + Files.createDirectories(artifactDir); + Files.copy(v1Jar2, artifactDir.resolve("v1-lib-1.0.1.jar")); + Files.writeString(artifactDir.resolve("v1-lib-1.0.1.pom"), """ + + 4.0.0 + test + v1-lib + 1.0.1 + + """); + + // v2 source: Hello is @InternalApi and has breaking changes + Path v2Internal = testProjectDir.resolve("src/main/java/internal"); + Path v2Pkg = testProjectDir.resolve("src/main/java/pkg"); + Files.createDirectories(v2Internal); + + Files.writeString(v2Internal.resolve("InternalApi.java"), """ + package internal; + public @interface InternalApi {} + """); + + Files.writeString(v2Pkg.resolve("Hello.java"), """ + package pkg; + import internal.InternalApi; + @InternalApi + public class Hello { + public void greet() { } + public String getName() { return "hello"; } + } + """); + + // Build file with @InternalApi exclusion + Files.writeString(testProjectDir.resolve("build.gradle.kts"), """ + plugins { + id("java") + id("io.github.alien-tools.roseau") + } + + roseau { + mvnCoord = "test:v1-lib" + v1 = "1.0.1" + failOnBreaking = false + reportsDir = layout.buildDirectory.dir("reports/roseau") + + mvnRepo { + maven { url = uri("%s") } + } + + excludes { + annotation("internal.InternalApi") + } + + reports { + csv("roseau.csv") + } + } + """.formatted(v1Repo.toUri().toString())); + + BuildResult result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments("roseauCheck") + .withPluginClasspath() + .forwardOutput() + .build(); + + assertThat(result.task(":roseauCheck").getOutcome()) + .isEqualTo(TaskOutcome.SUCCESS); + + // @InternalApi on Hello should exclude it from the report + String csv = Files.readString( + testProjectDir.resolve("build/reports/roseau/roseau.csv")); + assertThat(csv).doesNotContain("pkg.Hello"); + // Util is not annotated — still appears in the header row + } + + private static void addDirToJar(Path base, Path dir, JarOutputStream jos) throws IOException { + try (var files = Files.list(dir)) { + for (Path f : files.toList()) { + if (Files.isDirectory(f)) { + addDirToJar(base, f, jos); + } else { + String name = base.relativize(f).toString().replace('\\', '/'); + jos.putNextEntry(new JarEntry(name)); + Files.copy(f, jos); + jos.closeEntry(); + } + } + } + } +} diff --git a/gradle-plugin/src/test/resources/test-project/baseline-src/pkg/Hello.java b/gradle-plugin/src/test/resources/test-project/baseline-src/pkg/Hello.java new file mode 100644 index 00000000..c1102f75 --- /dev/null +++ b/gradle-plugin/src/test/resources/test-project/baseline-src/pkg/Hello.java @@ -0,0 +1,14 @@ +package pkg; + +/** + * Baseline (v1) version of Hello — has a {@code legacy()} method + * that will be removed in v2, and {@code getName()} returning String. + */ +public class Hello { + public void greet() { System.out.println("hello v1"); } + + /** @deprecated removed in v2 */ + public void legacy() { } + + public String getName() { return "Hello"; } +} diff --git a/gradle-plugin/src/test/resources/test-project/baseline-src/pkg/Util.java b/gradle-plugin/src/test/resources/test-project/baseline-src/pkg/Util.java new file mode 100644 index 00000000..9a904489 --- /dev/null +++ b/gradle-plugin/src/test/resources/test-project/baseline-src/pkg/Util.java @@ -0,0 +1,5 @@ +package pkg; + +public class Util { + public static int add(int a, int b) { return a + b; } +} diff --git a/gradle-plugin/src/test/resources/test-project/build.gradle.kts b/gradle-plugin/src/test/resources/test-project/build.gradle.kts new file mode 100644 index 00000000..62dfdc61 --- /dev/null +++ b/gradle-plugin/src/test/resources/test-project/build.gradle.kts @@ -0,0 +1,101 @@ +plugins { + id("java") + // The roseau plugin. Make sure it has been published to mavenLocal first: + // cd ../../.. && gradle publishToMavenLocal + id("io.github.alien-tools.roseau") version "0.7.0-SNAPSHOT" +} + +// ------------------------------------------------------------------ +// 1. Build the v1 (baseline) JAR and publish it to a file-based +// Maven repository so Roseau can resolve it. +// ------------------------------------------------------------------ + +val baselineSrc = layout.projectDirectory.dir("baseline-src") +val baselineClasses = layout.buildDirectory.dir("baseline-classes") +val baselineJar = layout.buildDirectory.file("baseline/v1-lib-1.0.0.jar") +val localMavenRepo = layout.buildDirectory.dir("local-maven-repo") + +val compileBaseline by tasks.registering(JavaCompile::class) { + description = "Compiles the v1 baseline sources" + source = fileTree(baselineSrc) + destinationDirectory = baselineClasses + classpath = sourceSets.main.get().compileClasspath + options.release = 8 +} + +val jarBaseline by tasks.registering(Jar::class) { + description = "Packages the v1 baseline into a JAR" + dependsOn(compileBaseline) + archiveFileName = "v1-lib-1.0.0.jar" + destinationDirectory = layout.buildDirectory.dir("baseline") + from(baselineClasses) +} + +val publishBaseline by tasks.registering(Copy::class) { + description = "Publishes the v1 baseline JAR to a local Maven file-repository" + dependsOn(jarBaseline) + from(jarBaseline) + into(localMavenRepo.map { it.dir("test/v1-lib/1.0.0") }) + rename { "v1-lib-1.0.0.jar" } + + doLast { + // Minimal POM so the Maven resolver can locate the artifact + val pomDir = localMavenRepo.get().dir("test/v1-lib/1.0.0").asFile + pomDir.resolve("v1-lib-1.0.0.pom").writeText(""" + + 4.0.0 + test + v1-lib + 1.0.0 + + """.trimIndent()) + } +} + +// The roseauCheck task must wait until the baseline has been published +tasks.named("roseauCheck") { + dependsOn(publishBaseline) +} + +// ------------------------------------------------------------------ +// 2. Roseau plugin configuration +// ------------------------------------------------------------------ + +roseau { + // Maven coordinates of the library we're analysing + mvnCoord = "test:v1-lib" + + // Baseline version — resolved from the local file repo + v1 = "1.0.0" + + // When v2 is omitted the current project's JAR (jar task) is used. + // That's exactly what we want: compare baseline-src vs src/main/java. + + // Fail if breaking changes are found + failOnBreaking = true + + // Directory where reports are written + reportsDir = layout.buildDirectory.dir("reports/roseau") + + // The local file repo where we published the baseline + mvnRepo { + maven { url = uri(localMavenRepo.get().asFile.toURI()) } + } + + // -- Exclude certain symbols from the report -- + excludes { + // Exclude by regex (example — not used in this test project) + // names = listOf("pkg\\.Util.*") + + // Exclude by annotation + // annotation("java.lang.Deprecated") + // annotation("org.apiguardian.api.API") { arg("status", "INTERNAL") } + } + + // -- Generate reports in multiple formats -- + reports { + csv("roseau.csv") + html("roseau.html") + json("roseau.json") + } +} diff --git a/gradle-plugin/src/test/resources/test-project/settings.gradle.kts b/gradle-plugin/src/test/resources/test-project/settings.gradle.kts new file mode 100644 index 00000000..b9e868dc --- /dev/null +++ b/gradle-plugin/src/test/resources/test-project/settings.gradle.kts @@ -0,0 +1,7 @@ +pluginManagement { + repositories { + mavenLocal() + mavenCentral() + } +} +rootProject.name = "roseau-test-project" diff --git a/gradle-plugin/src/test/resources/test-project/src/main/java/pkg/Hello.java b/gradle-plugin/src/test/resources/test-project/src/main/java/pkg/Hello.java new file mode 100644 index 00000000..2e68cab9 --- /dev/null +++ b/gradle-plugin/src/test/resources/test-project/src/main/java/pkg/Hello.java @@ -0,0 +1,15 @@ +package pkg; + +/** + * v2 version of Hello — {@code legacy()} is removed, + * {@code getName()} returns CharSequence instead of String. + * Both are breaking changes. + */ +public class Hello { + public void greet() { System.out.println("hello v2"); } + + // legacy() removed — EXECUTABLE_REMOVED (binary-breaking + source-breaking) + + public CharSequence getName() { return "Hello v2"; } + // return type changed — METHOD_RETURN_TYPE_ERASURE_CHANGED +} diff --git a/gradle-plugin/src/test/resources/test-project/src/main/java/pkg/Util.java b/gradle-plugin/src/test/resources/test-project/src/main/java/pkg/Util.java new file mode 100644 index 00000000..9a904489 --- /dev/null +++ b/gradle-plugin/src/test/resources/test-project/src/main/java/pkg/Util.java @@ -0,0 +1,5 @@ +package pkg; + +public class Util { + public static int add(int a, int b) { return a + b; } +} diff --git a/mkdocs.yml b/mkdocs.yml index dd5ae8e7..8f0f66e4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,3 +54,4 @@ nav: - Reference: - CLI options: reference/cli.md - Maven plug-in options: reference/maven-plugin.md + - Gradle plug-in options: reference/gradle-plugin.md