rewrite-runner

Build CodeQL LICENSE

A self-hosted CLI tool for running OpenRewrite recipes against arbitrary repositories — without requiring the target project's build to be working.

Features

  • Run any OpenRewrite recipe against a local project directory

  • Works even when the project's build is broken, credentials are missing, or private registries are unavailable

  • Automatically downloads recipe JARs from Maven coordinates — no manual dependency management

  • Supports Java, Kotlin, Groovy, YAML, JSON, XML, Properties, TOML, HCL/Terraform, Protobuf, Dockerfile, and plain-text mask files (pom.xml uses MavenParser for full Maven recipe support)

  • Three output modes: unified diffs, changed file paths, or a structured JSON report

  • Composable recipes via rewrite.yaml

  • Configurable Maven repositories for enterprise environments with private Nexus/Artifactory

Installation

rewrite-runner is published to Maven Central. The core module is the library; the cli module ships a thin JAR plus a -all fat JAR for direct CLI use.

Gradle

// build.gradle.kts
dependencies {
implementation("io.github.skhokhlov.rewriterunner:core:1.0.0")
}

Maven

<dependency>
<groupId>io.github.skhokhlov.rewriterunner</groupId>
<artifactId>core</artifactId>
<version>1.0.0</version>
</dependency>

CLI fat JAR

Download the -all jar directly from Maven Central:

curl -L -o rewrite-runner.jar \
"https://repo1.maven.org/maven2/io/github/skhokhlov/rewriterunner/cli/1.0.0/cli-1.0.0-all.jar"
java -jar rewrite-runner.jar --help

Getting Started

Build

Requires JDK 21+.

./gradlew shadowJar
# Produces: cli/build/libs/cli-1.0-SNAPSHOT-all.jar

Run a recipe

java -jar cli/build/libs/cli-1.0-SNAPSHOT-all.jar \
--project-dir /path/to/your/project \
--active-recipe org.openrewrite.java.format.AutoFormat \
--recipe-artifact org.openrewrite.recipe:rewrite-static-analysis:LATEST

Dry run (preview changes without writing to disk)

java -jar cli/build/libs/cli-1.0-SNAPSHOT-all.jar \
--project-dir /path/to/your/project \
--active-recipe org.openrewrite.java.migrate.UpgradeToJava21 \
--recipe-artifact org.openrewrite.recipe:rewrite-migrate-java:LATEST \
--dry-run \
--output diff

Library Usage

rewrite-runner can be used as a library from Java and Kotlin code without the CLI layer. Use the plain JAR (not the -all fat JAR) as a dependency.

Adding as a dependency

// build.gradle.kts — Maven Central (recommended)
dependencies {
implementation("io.github.skhokhlov.rewriterunner:core:1.0.0")
}
// build.gradle.kts — local JAR (for development)
dependencies {
implementation(files("libs/rewrite-runner-core-1.0-SNAPSHOT.jar"))
}

Kotlin usage

import io.github.skhokhlov.rewriterunner.RewriteRunner
import java.nio.file.Paths
import java.time.Duration

fun main() {
val result = RewriteRunner.builder()
.projectDir(Paths.get("/path/to/project"))
.activeRecipe("org.openrewrite.java.format.AutoFormat")
.recipeArtifact("org.openrewrite.recipe:rewrite-static-analysis:LATEST")
.processTimeout(Duration.ofSeconds(120))
.dryRun(true) // preview changes without writing to disk
.build()
.run()

println("Changed ${result.changeCount} file(s)")
result.results.forEach { r -> println(r.diff()) }
}

Java usage

import io.github.skhokhlov.rewriterunner.RewriteRunner;
import io.github.skhokhlov.rewriterunner.RunResult;
import java.nio.file.Paths;
import java.time.Duration;

public class Example {
public static void main(String[] args) {
RunResult result = RewriteRunner.builder()
.projectDir(Paths.get("/path/to/project"))
.activeRecipe("org.openrewrite.java.format.AutoFormat")
.recipeArtifact("org.openrewrite.recipe:rewrite-static-analysis:LATEST")
.processTimeout(Duration.ofSeconds(120))
.dryRun(true)
.build()
.run();

System.out.println("Changed " + result.getChangeCount() + " file(s)");
result.getResults().forEach(r -> System.out.println(r.diff()));
}
}

Working with results

RunResult gives you raw access to everything the recipe produced:

val result = runner.run()

println("Changed: ${result.hasChanges}") // true/false
println("Files changed: ${result.changeCount}") // results.size + rawDiffs.size

// Iterate raw OpenRewrite results
result.results.forEach { r ->
// r.before — source file before the recipe (null for newly created files)
// r.after — source file after the recipe (null for deleted files)
println(r.diff()) // unified diff string
println(r.after?.sourcePath) // relative path of the changed file
}

// Plugin-first raw diffs may be populated alongside results when the
// Stage 0 specialized ownership pass also changed Docker/HCL/protobuf files.
result.rawDiffs.forEach { (path, diff) -> println("$path\n$diff") }

// changedFiles: paths written to disk (empty in dry-run mode)
result.changedFiles.forEach { path -> println("Written: $path") }

// Per-file parse failures (no need to scrape logs)
result.executionDiagnostics.parseFailures.forEach { failure ->
println("${failure.parser} could not handle ${failure.path}: ${failure.reason}")
}

// OpenRewrite's estimate of manual effort avoided, or null when unavailable.
println("Estimated time saved: ${result.executionDiagnostics.estimatedTimeSaved}")

// Null means a plugin-only path ran and no in-process LST count was measured.
println("Parsed files: ${result.executionDiagnostics.parsedFileCount}")

Per-file parse failures are collected into executionDiagnostics.parseFailures rather than aborting the build; callers can surface or ignore them. The one intentional exception: a non-URI MavenParser throw aborts the LST build by design (silently downgrading it to XmlParser would hide regressions and produce misleading recipe results). URI-class MavenParser failures still fall back to XmlParser and are recorded normally. Fatal Errors (e.g. OutOfMemoryError) always propagate. See docs/library-api.md#parse-failures for the full shape.

Formatted output (ResultFormatter)

For the same three output modes as the CLI (diff, files, report), use ResultFormatter:

import io.github.skhokhlov.rewriterunner.output.OutputMode
import io.github.skhokhlov.rewriterunner.output.ResultFormatter

val result = runner.run()

// Print unified diffs to stdout
ResultFormatter(OutputMode.DIFF).format(result)

// Print only the paths of changed files (one per line)
ResultFormatter(OutputMode.FILES).format(result)

// Write openrewrite-report.json to the project directory
ResultFormatter(OutputMode.REPORT).format(result)

OutputMode values:

Value Behaviour
DIFF Prints a unified diff for each changed file to stdout (default CLI mode)
FILES Prints one changed-file path per line to stdout
REPORT Writes openrewrite-report.json to the reportDir argument (defaults to .)

Library consumers that only need to inspect changes programmatically can skip ResultFormatter entirely and work with RunResult.results / RunResult.rawDiffs directly.

Logging

By default the core library produces no log output — all logging is suppressed via NoOpRunnerLogger. To receive pipeline events, implement RunnerLogger and pass it to the builder:

import io.github.skhokhlov.rewriterunner.RunnerLogger

class PrintlnLogger : RunnerLogger {
override fun lifecycle(message: String) = println("[LIFECYCLE] $message")
override fun info(message: String) = println("[INFO] $message")
override fun debug(message: String) = println("[DEBUG] $message")
override fun warn(message: String) = println("[WARN] $message")
override fun error(message: String, cause: Throwable?) {
println("[ERROR] $message")
cause?.printStackTrace()
}
}

val result = RewriteRunner.builder()
.projectDir(Paths.get("/path/to/project"))
.activeRecipe("org.openrewrite.java.format.AutoFormat")
.logger(PrintlnLogger()) // wire your logger here
.build()
.run()

Log levels and what they emit:

Level What you receive
lifecycle Pipeline stage headers and summary results (always relevant)
info Per-language file counts, stage status, artifact resolution progress
debug Per-file version detection, recipe JAR scanning
warn Recoverable problems (e.g. 404 during artifact resolution)
error Fatal failures, always with an optional cause

NoOpRunnerLogger (the default) silently discards all messages. The CLI wires its own Logback-backed implementation; --info and --debug flags control which levels are forwarded to Logback.

Enterprise and private registry setup

When Maven Central is unreachable, provide your private registry directly via the builder:

import io.github.skhokhlov.rewriterunner.config.RepositoryConfig

val result = RewriteRunner.builder()
.projectDir(Paths.get("/path/to/project"))
.activeRecipe("org.openrewrite.java.format.AutoFormat")
.recipeArtifact("org.openrewrite.recipe:rewrite-static-analysis:LATEST")
.artifactRepository(RepositoryConfig(
url = "https://nexus.example.com/repository/maven-public",
username = System.getenv("NEXUS_USER"),
password = System.getenv("NEXUS_PASS")
))
.includeMavenCentral(false) // restrict resolution to the Nexus repository only
.build()
.run()

Programmatic composite recipes

Instead of writing a rewrite.yaml file on disk, you can supply the YAML content as a string directly via rewriteConfigContent:

val recipeYaml = """
type: specs.openrewrite.org/v1beta/recipe
name: com.example.MyMigration
displayName: My Custom Migration
recipeList:
- org.openrewrite.java.migrate.UpgradeToJava21
- org.openrewrite.java.format.AutoFormat
""".trimIndent()

val result = RewriteRunner.builder()
.projectDir(Paths.get("/path/to/project"))
.activeRecipe("com.example.MyMigration")
.recipeArtifact("org.openrewrite.recipe:rewrite-migrate-java:LATEST")
.rewriteConfigContent(recipeYaml) // no file required
.build()
.run()

CLI Reference

Usage: rewrite-runner [-h] [--dry-run] [--skip-plugin-run] [--info] [--debug]
[--no-maven-central]
[--active-recipe=<recipe>]
[--cache-dir=<path>] [--config=<path>]
[--artifact-download-threads=<n>]
[--subprocess-run-timeout=<duration>]
[--plugin-run-timeout<duration>]
[--artifact-resolver-connect-timeout=<duration>]
[--artifact-resolver-request-timeout=<duration>]
[--output=<mode>] [--project-dir=<path>]
[--rewrite-config=<path>]
[--exclude-paths=<glob>[,<glob>...]]
[--plain-text-masks=<glob>[,<glob>...]]
[--recipe-artifact=<coord>]...
Option Description Default
--project-dir, -p Project directory to refactor . (current directory)
--active-recipe, -r Fully-qualified recipe name to run (required)
--recipe-artifact Maven coordinate of a recipe JAR to load (repeatable)
--rewrite-config Path to rewrite.yaml for custom recipe compositions <project-dir>/rewrite.yaml
--output, -o Output mode: diff, files, or report diff
--cache-dir Cache root for downloaded recipe JARs (stored under <path>/repository). Project dependencies always resolve from ~/.m2/repository. ~/.rewriterunner/cache
--config Path to tool config file (rewriterunner.yml) <project-dir>/rewriterunner.yml, then ~/.rewriterunner/rewriterunner.yml
--dry-run Run recipe but do not write changes to disk false
--skip-plugin-run Skip plugin-first execution; use full LST pipeline directly false
--artifact-download-threads Number of parallel artifact download threads 5
--subprocess-run-timeout Timeout for build-tool subprocesses in the fallback LST pipeline. Accepts ms, s, m, h, d, or ISO-8601 values. 120s
--plugin-run-timeout Timeout for plugin-first Gradle/Maven invocations. Accepts ms, s, m, h, d, or ISO-8601 values. 10m
--artifact-resolver-connect-timeout TCP connection timeout for Maven Resolver downloads. Accepts ms, s, m, h, d, or ISO-8601 values. 30s
--artifact-resolver-request-timeout Socket read/request timeout for Maven Resolver downloads. Accepts ms, s, m, h, d, or ISO-8601 values. 60s
--exclude-paths Comma-separated glob patterns of files to skip (e.g. **/generated/**,**/*.md). Forwarded to both the Stage 0 plugin (Maven: -Drewrite.exclusions=…; Gradle: exclusion(...) DSL) and to the LST fallback pipeline. Stage 0 also receives Docker/HCL/protobuf ownership exclusions.
--plain-text-masks Comma-separated glob patterns of otherwise-unhandled files to parse as plain text (e.g. **/CODEOWNERS,**/*.txt). Replaces the upstream default mask list when specified and is forwarded to both Stage 0 and the LST fallback pipeline. upstream defaults
--no-maven-central Disable Maven Central; use only repositories from the config file false
--info Enable INFO-level logging to stderr false
--debug Enable DEBUG-level logging to stderr (overrides --info) false

Output modes

--output diff (default) — prints unified diffs for each changed file:

--- a/src/main/java/Hello.java
+++ b/src/main/java/Hello.java

-1,3 +1,5 @@ public class Hello {

  • public static void main(String[]args){System.out.println("hi");}

  • public static void main(String[] args) {

  •    System.out.println("hi");
  • } }

**`--output files`** — prints only the paths of changed files, one per line.

**`--output report`** — writes `openrewrite-report.json` to the project directory:
```json
{
"totalChanged": 1,
"results": [
{
"filePath": "src/main/java/Hello.java",
"diff": "...",
"isNewFile": false,
"isDeletedFile": false
}
],
"parsedFileCount": 1,
"parseFailures": [
{
"path": "src/main/java/Broken.java",
"reason": "unterminated comment",
"parser": "JavaParser"
}
]
}

parsedFileCount is the number of successfully parsed source files in the LST path, excluding ParseError stubs. It is null for plugin-first runs because the in-process LST was not built.

estimatedTimeSaved is available to library callers as RunResult.executionDiagnostics.estimatedTimeSaved, but is not serialized into openrewrite-report.json.

parseFailures is empty when every file parsed cleanly. Each entry names the producer that gave up on the entry along with a short reason. Two kinds of producers appear:

  • A canonical parser (JavaParser, MavenParser, XmlParser, …) — path is the project-relative source file. A file can appear more than once if multiple parsers tried and failed on it (the Maven POM → XML fallback path is the typical case).

  • A classpath-resolution stage (DependencyResolutionStage, BuildFileParseStage) — path is the rejected Maven coordinate string itself (not a file path), and reason is "illegal Maven coordinate". Malformed coordinates encountered while assembling the LST classpath are skipped rather than aborting the build.

Recipe Artifacts

Specify recipe JARs using Maven coordinates. The --recipe-artifact flag can be repeated to load multiple recipe modules.

# Load a single recipe module
--recipe-artifact org.openrewrite.recipe:rewrite-spring:LATEST

# Load multiple modules
--recipe-artifact org.openrewrite.recipe:rewrite-migrate-java:LATEST \
--recipe-artifact org.openrewrite.recipe:rewrite-spring:LATEST

LATEST resolves to the most recent release. Specific versions (e.g. 2.21.0) are also accepted.

Downloaded recipe JARs are cached under ~/.rewriterunner/cache/repository (or --cache-dir/repository) and reused on subsequent runs. They are stored separately from the project's own dependencies, which always resolve from ~/.m2/repository.

Only compile/runtime JARs are downloaded for recipe artifacts — test-scoped and provided-scoped transitive dependencies of recipes are skipped.

Custom Recipe Compositions

Define composite recipes in a rewrite.yaml file in your project directory (or pass --rewrite-config):

---
type: specs.openrewrite.org/v1beta/recipe
name: com.example.MyMigration
displayName: My Custom Migration
recipeList:
- org.openrewrite.java.migrate.UpgradeToJava21
- org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3
- org.openrewrite.java.format.AutoFormat

Then run it:

java -jar rewrite-runner-all.jar \
--project-dir /path/to/project \
--active-recipe com.example.MyMigration \
--recipe-artifact org.openrewrite.recipe:rewrite-migrate-java:LATEST \
--recipe-artifact org.openrewrite.recipe:rewrite-spring:LATEST

Tool Config File

Create rewriterunner.yml to configure repositories and caching for your environment.

Default locations (checked in order):

  1. <project-dir>/rewriterunner.yml — project-level config

  2. ~/.rewriterunner/rewriterunner.yml — global fallback, shared across all projects

File name matching is case-insensitive (e.g. RewriteRunner.yml also works). Override either default with --config <path>. Duration values require units such as 30000ms, 120s, 10m, 2h, 1d, or ISO-8601 values such as PT2M.

repositories:
- url: https://nexus.example.com/repository/maven-public
username: ${NEXUS_USER}
password: ${NEXUS_PASSWORD}

cacheDir: ~/.rewriterunner/cache

downloadThreads: 5 # parallel artifact download threads (default: 5)
processTimeout: 120s # fallback LST build-tool subprocess timeout
pluginTimeout: 10m # plugin-first rewriteDryRun/rewriteRun timeout
rewriteGradlePluginVersion: 7.32.1
rewriteMavenPluginVersion: 6.40.0
resolverConnectTimeout: 30s # Maven Resolver TCP connection timeout
resolverRequestTimeout: 60s # Maven Resolver socket/request timeout

parse:
excludePaths:
- "**/generated/**"
- "**/*.md"
plainTextMasks:
- "**/CODEOWNERS"
- "**/*.txt"

Environment variable placeholders (${VAR_NAME}) are expanded at runtime.

Plugin-First Execution

Before building its own LST, the tool first attempts to apply the recipe through the official OpenRewrite plugin for the project's build tool:

  • Gradle: injects a temporary init script that applies the org.openrewrite.rewrite plugin, then runs rewriteDryRun and, when not in --dry-run mode, rewriteRun

  • Maven: invokes org.openrewrite.maven:rewrite-maven-plugin directly via ./mvnw, mvnw.cmd, or system mvn

The dry-run goal always runs first so the tool can capture generated rewrite.patch files and format output in diff, files, or report mode. Gradle patches are read from build/reports/rewrite/rewrite.patch; Maven patches are pinned to a private report directory through -DreportOutputDirectory. All plugin patch paths are reported relative to the project root. If no patches contain changes, the run short-circuits with no changes. If plugin execution succeeds with changes, the in-process LST pipeline is skipped entirely.

Stage 0 also exposes ExecutionDiagnostics.estimatedTimeSaved. It requests OpenRewrite data-table export and reads the latest SourcesFileResults table when present; current Maven/Gradle plugin versions may only emit the same OpenRewrite-computed value in the Estimate time saved output line, so rewrite-runner falls back to that line. It never estimates the value from changed-file count.

If the plugin path fails for any reason (no build tool, plugin resolution failure, build error, recipe unavailable, timeout), the tool falls through silently to the fallback pipeline below.

This plugin-first stage is enabled by default. Existing callers that need the previous direct LST pipeline behavior should pass --skip-plugin-run or set skipPluginRun(true).

Use --skip-plugin-run to bypass this stage.

Resilient Parsing Pipeline

The tool uses a four-stage fallback pipeline to build the LST, ensuring recipes run even on projects with broken builds.

Stage 1 — Build tool classpath extraction

Invokes the project's own build tool as a subprocess to extract the full compile classpath:

  • Maven: mvn dependency:build-classpath -DincludeScope=compile

  • Gradle: injects a temporary init script that prints runtimeClasspath file paths

If successful, the resulting JAR list is passed to JavaParser for full type attribution (resolves imports, method signatures, type hierarchies).

Stage 2 — Direct dependency resolution

If Stage 1 fails (broken build, no wrapper, timeout), the tool resolves dependencies without running the full build:

  • Maven: parses pom.xml using maven-model. Includes compile, provided, and test scopes; excludes runtime and system scopes — provided and test dependencies are included to support compile-time and test-source type resolution while runtime-only artifacts are skipped.

  • Gradle: runs gradle dependencies for the root project and all declared subprojects (discovered from settings.gradle / settings.gradle.kts), parsing the resolved dependency tree to get accurately resolved versions; falls back to best-effort static regex parsing of build.gradle / build.gradle.kts if Gradle cannot be invoked.

Note: The gradle dependencies task only reports dependencies for the project it is applied to. Subprojects are queried explicitly (:sub:dependencies) so that multi-module builds are fully covered.

Direct deps only, no POM traversal. Stage 2 downloads JARs only for the dependencies explicitly declared in the build file — it does not traverse transitive dependency graphs or fetch transitive POM files. This avoids hundreds of extra HTTP requests on a cold run. Missing transitive types appear as JavaType.Unknown, which OpenRewrite handles gracefully.

Resolved JARs are cached in ~/.m2/repository (Maven default), so artifacts already downloaded by the project's own build are reused without re-downloading. Extra repositories from the tool config are also consulted.

Stage 3 — Static build file parse + POM traversal

If Stage 2 fails, the tool statically parses build files without invoking any subprocess, then resolves transitive dependencies via Maven Resolver POM traversal:

  • Maven: discovers all modules via pom.xml module declarations and directory walk, then resolves the full transitive dependency graph.

  • Gradle: statically parses build.gradle(.kts) files and version catalogs (gradle/*.versions.toml) using regex extraction, then resolves transitives via Maven Resolver POM traversal.

This stage provides full transitive dependency resolution without requiring a working build tool installation.

Stage 4 — Local cache scan

If all previous stages fail, the tool scans local Maven and Gradle caches:

  • ~/.m2/repository for Maven-cached JARs

  • ~/.gradle/caches/modules-*/files-*/ for Gradle-cached JARs

Unresolved types appear as JavaType.Unknown in the LST, but all structural, text-based, YAML, XML, and search recipes continue to work correctly.

Supported File Types

Extension Parser
.java JavaParser (with classpath from fallback pipeline)
.kt, .kts KotlinParser (.kts augmented with Gradle DSL classpath)
.groovy, .gradle GroovyParser (.gradle augmented with Gradle DSL classpath)
.yaml, .yml YamlParser
.json JsonParser
pom.xml MavenParser (fully resolved — parent POMs, property interpolation, BOM imports; enables full rewrite-maven recipe catalog)
*.xml (other) XmlParser
.properties PropertiesParser
.toml TomlParser
.hcl, .tf, .tfvars HclParser
.proto ProtoParser
.dockerfile, .containerfile, Dockerfile*, Containerfile* DockerParser (matched both by extension and by filename prefix)
Plain text mask matches, e.g. CODEOWNERS, *.md, *.sh, *.txt PlainTextParser

All supported extensions and the upstream default plain-text masks are parsed by default. Use --exclude-paths (CLI), parse.excludePaths (YAML), or Builder.excludePaths(...) (library) to skip specific paths via glob patterns. Use --plain-text-masks, parse.plainTextMasks, or Builder.plainTextMasks(...) to replace the plain-text mask list. Exclusions win, and specialized parsers take precedence over plain-text masks on the LST path. The resolved values are forwarded to the Stage 0 plugin invocation and to the LST fallback pipeline. On Stage 0 success, Docker/HCL/protobuf files are excluded from the plugin and handled by rewrite-runner's restricted specialized parser pass.

Automatically excluded directories

The following directories are always skipped during the file-system walk, regardless of configuration:

.git, build, target, node_modules, .gradle, .idea, out, dist

Use parse.excludePaths in rewriterunner.yml, --exclude-paths on the CLI, or Builder.excludePaths(...) in the library to skip additional paths.

Development

# Run all tests
./gradlew test

# Run a specific test class
./gradlew test --tests "io.github.skhokhlov.rewriterunner.output.ResultFormatterTest"

# Build and run locally
./gradlew shadowJar
java -jar cli/build/libs/cli-1.0-SNAPSHOT-all.jar --help

All modules:

Link copied to clipboard
Link copied to clipboard