rewrite-runner
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, and Dockerfile files (
pom.xmlusesMavenParserfor 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
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")
.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;
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")
.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}") // number of changed files
// 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
}
// changedFiles: paths written to disk (empty in dry-run mode)
result.changedFiles.forEach { path -> println("Written: $path") }
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.results, result.projectDir)
// Print only the paths of changed files (one per line)
ResultFormatter(OutputMode.FILES).format(result.results, result.projectDir)
// Write openrewrite-report.json to the project directory
ResultFormatter(OutputMode.REPORT).format(result.results, result.projectDir)
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 directly.
Builder reference
| Method | Type | Default | Description |
|---|---|---|---|
projectDir(Path) |
required | . (cwd) |
Project root to analyse |
activeRecipe(String) |
required | — | Fully-qualified recipe name |
recipeArtifact(String) |
optional (repeatable) | — | Maven coordinate of a recipe JAR; may be called multiple times |
recipeArtifacts(List<String>) |
optional | — | Set all recipe artifact coordinates at once |
rewriteConfig(Path) |
optional | <projectDir>/rewrite.yaml |
Path to a rewrite.yaml for custom composite recipes |
rewriteConfigContent(String) |
optional | — | Raw rewrite.yaml content as a string; takes precedence over rewriteConfig when both are set |
cacheDir(Path) |
optional | ~/.rewriterunner/cache |
Cache root for downloaded recipe JARs (stored under <cacheDir>/repository). Project dependencies always resolve from ~/.m2/repository. |
configFile(Path) |
optional | auto-discovered | Path to rewriterunner.yml; auto-discovery checks <projectDir>/rewriterunner.yml then ~/.rewriterunner/rewriterunner.yml |
dryRun(Boolean) |
optional | false |
Analyse without writing to disk |
includeExtensions(List<String>) |
optional | all supported | File extensions to parse; overrides config file setting |
excludeExtensions(List<String>) |
optional | — | File extensions to skip; overrides config file setting |
excludePaths(List<String>) |
optional | — | Glob patterns (relative to project root) for paths to skip during parsing; overrides parse.excludePaths from config file |
downloadThreads(Int) |
optional | 5 |
Number of parallel artifact download threads. Increase for fast networks; decrease in resource-constrained environments. |
includeMavenCentral(Boolean) |
optional | true |
Include Maven Central as a resolution repository. Set to false for air-gapped or enterprise environments. |
repository(RepositoryConfig) |
optional (repeatable) | — | Add one extra Maven repository for artifact resolution; combined with repositories from config file |
repositories(List<RepositoryConfig>) |
optional | — | Set all extra Maven repositories at once; combined with repositories from config file |
logger(RunnerLogger) |
optional | NoOpRunnerLogger |
Receives pipeline log events; implement to capture lifecycle, info, debug, warn, and error messages |
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")
.repository(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] [--info] [--debug] [--no-maven-central]
[--active-recipe=<recipe>]
[--cache-dir=<path>] [--config=<path>]
[--download-threads=<n>]
[--output=<mode>] [--project-dir=<path>]
[--rewrite-config=<path>]
[--exclude-extensions=<ext>[,<ext>...]]
[--include-extensions=<ext>[,<ext>...]]
[--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 |
--download-threads |
Number of parallel artifact download threads | 5 |
--include-extensions |
Comma-separated file extensions to parse (e.g. .java,.kt) |
all supported |
--exclude-extensions |
Comma-separated file extensions to skip | — |
--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");Content copied to clipboard -
} }
**`--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
}
]
}
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):
-
<project-dir>/rewriterunner.yml— project-level config -
~/.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>.
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)
parse:
includeExtensions: [".java", ".kt", ".xml"]
excludeExtensions: [".properties"]
excludePaths:
- "**/generated/**"
- "**/build/**"
Environment variable placeholders (${VAR_NAME}) are expanded at runtime.
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
runtimeClasspathfile 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.xmlusingmaven-model. All scopes exceptprovidedandsystemare included — test-scoped dependencies (JUnit, Mockito, etc.) are resolved so that test source files can be fully type-attributed. -
Gradle: runs
gradle dependenciesfor the root project and all declared subprojects (discovered fromsettings.gradle/settings.gradle.kts), parsing the resolved dependency tree to get accurately resolved versions; falls back to best-effort static regex parsing ofbuild.gradle/build.gradle.ktsif Gradle cannot be invoked.
Note: The
gradle dependenciestask 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.xmlmodule 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/repositoryfor 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 3-stage 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) |
The parsed file set is configurable via --include-extensions, --exclude-extensions, and the parse section of rewriterunner.yml.
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 (or --exclude-extensions / excludePaths() in the library API) 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