1
0
mirror of https://github.com/google/nomulus synced 2026-05-30 19:46:34 +00:00

Add java-ast-refactoring skill (#3064)

This adds a Gemini CLI skill that leverages OpenRewrite to perform Abstract Syntax Tree (AST) based refactoring on Java codebases. It is highly preferred over text-based regex or python scripts because it understands Java semantics, correctly updates imports, and preserves formatting. A custom Python script is also included as a fallback for renaming fields and local variables.
This commit is contained in:
Ben McIlwain
2026-05-27 14:33:38 -04:00
committed by GitHub
parent ba91141505
commit ad992beff9
4 changed files with 263 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
---
name: java-ast-refactoring
description: "AST-aware Java refactoring using OpenRewrite. Use when asked to structurally refactor Java code, change class names, change method signatures/overloads, replace builder patterns, modify annotations, or perform cross-file structural replacements. Note: Renaming fields or local variables/parameters is not supported natively via simple YAML recipes in the standard openrewrite modules."
---
# AST-Aware Java Refactoring
This skill uses OpenRewrite to perform Abstract Syntax Tree (AST) based refactoring on Java codebases. This is highly preferred over text-based regex or python scripts because it understands Java semantics, correctly updates imports, and preserves formatting.
## Parameter and Field Renaming (Last Resort)
Because OpenRewrite's YAML recipes do not natively support simple variable or field renaming, a custom script is provided:
```bash
python3 .gemini/skills/java-ast-refactoring/scripts/safe_rename.py <filepath> <old_name> <new_name>
```
**CRITICAL:** Running this python script is a LAST RESORT. It is a regex-based token replacement that ignores strings and comments, but it lacks true AST understanding. ALWAYS prefer using OpenRewrite recipes (`rewrite.yml`) for structural changes like renaming classes, methods, or moving targets, as OpenRewrite correctly handles imports, types, and cross-file references safely.
## Usage
1. Create a `rewrite.yml` recipe file in the workspace root. Refer to `.gemini/skills/java-ast-refactoring/references/rewrite_recipes.md` for syntax.
2. Execute the script:
```bash
./.gemini/skills/java-ast-refactoring/scripts/run_rewrite.sh rewrite.yml
```
3. The script will safely apply the AST transformations and then automatically run `./gradlew spotlessApply` and `google-java-format --replace` on the modified files to automatically fix any Checkstyle line-length and import ordering issues caused by longer/shorter identifiers. Verify the output using `git diff`.
4. **MANDATORY:** Always run `./gradlew build -x test` (or the equivalent compile task) after running OpenRewrite to ensure no compilation errors were introduced.
## Known Limitations & Troubleshooting
* **Static Imports Dropped on Class Rename:** When using `ChangeType` to rename a class, OpenRewrite may sometimes drop static imports for fields/constants belonging to the old class instead of updating them to the new class. If compilation fails due to "cannot find symbol" for a constant after a class rename, manually restore the static import (e.g., `import static com.new.ClassName.CONSTANT;`).
* **Continuous Improvement:** If any new issues or edge cases are found while running the refactoring (e.g., build failures, formatting issues, or missed transformations), proactively update this skill file (`SKILL.md`) and its accompanying scripts (`scripts/run_rewrite.sh`, `scripts/safe_rename.py`) to permanently fix the issue for future use.

View File

@@ -0,0 +1,82 @@
# OpenRewrite Recipe Reference
OpenRewrite uses declarative YAML recipes to perform structural refactorings.
## Recipe Structure
A recipe file must have a `type`, a `name` (which you will activate), and a `recipeList` containing specific core recipes to execute sequentially.
```yaml
type: specs.openrewrite.org/v1beta/recipe
name: com.example.MyRefactoring
recipeList:
- <CoreRecipe>:
<argument1>: <value>
```
## Core Recipes for Common Operations
### 1. Change Method Name
```yaml
- org.openrewrite.java.ChangeMethodName:
methodPattern: java.util.Collections emptyList()
newMethodName: emptyList
```
### 2. Change Method Target to Static
Moves a method call to a new static method target. Useful for replacing custom utility methods with standard ones.
```yaml
- org.openrewrite.java.ChangeMethodTargetToStatic:
methodPattern: google.registry.model.eppinput.EppInputs createDomain(java.lang.String, java.lang.String)
fullyQualifiedTargetTypeName: google.registry.model.domain.DomainCommand.Create
returnType: google.registry.model.domain.DomainCommand.Create.Builder
```
### 3. Change Type (Rename/Move Class)
Updates the class name and automatically updates all imports across the codebase.
*Note: OpenRewrite occasionally drops `import static` references to fields inside the renamed class. Be prepared to manually restore them if a compilation error occurs.*
```yaml
- org.openrewrite.java.ChangeType:
oldFullyQualifiedTypeName: org.joda.time.Instant
newFullyQualifiedTypeName: java.time.Instant
```
### 4. Remove Unused Imports
```yaml
- org.openrewrite.java.RemoveUnusedImports
```
### 5. Change Annotation
```yaml
- org.openrewrite.java.ChangeAnnotation:
annotationPattern: @org.junit.Ignore
newAnnotation: @org.junit.jupiter.api.Disabled
```
### 6. Remove Annotation
```yaml
- org.openrewrite.java.RemoveAnnotation:
annotationPattern: @java.lang.SuppressWarnings("unchecked")
```
### 7. Change Method Arguments
Reorders or removes arguments based on a target signature. `newArgumentTemplate` uses 0-based indexing.
```yaml
- org.openrewrite.java.ChangeMethodAccessLevel:
methodPattern: com.google.common.collect.ImmutableList of(..)
newAccessLevel: protected
```
### 8. Add Import
```yaml
- org.openrewrite.java.AddImport:
type: java.util.List
```
## Method Patterns
OpenRewrite uses a specific pointcut expression language for `methodPattern`:
* `[return-type] [fully-qualified-class-name] [method-name]([parameter-types])`
* `*` matches any type.
* `..` matches any number of parameters.
* Example: `java.lang.String split(java.lang.String, int)`
* Example: `* java.util.List add(..)`

View File

@@ -0,0 +1,87 @@
#!/bin/bash
# Copyright 2026 The Nomulus Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Wrapper script to dynamically execute OpenRewrite without modifying build.gradle
if [ -z "$1" ]; then
echo "Usage: $0 <path-to-rewrite.yml>"
exit 1
fi
RECIPE_FILE=$(realpath "$1")
if [ ! -f "$RECIPE_FILE" ]; then
echo "Error: Recipe file $RECIPE_FILE not found."
exit 1
fi
# Extract the name of the recipe from the YAML to activate it
RECIPE_NAME=$(grep -oP '(?<=name: ).*' "$RECIPE_FILE" | head -n 1)
if [ -z "$RECIPE_NAME" ]; then
echo "Error: Could not extract 'name:' from $RECIPE_FILE"
exit 1
fi
INIT_SCRIPT="rewrite-init.gradle"
cat << EOF > "$INIT_SCRIPT"
initscript {
repositories {
mavenCentral()
gradlePluginPortal()
}
dependencies {
classpath("org.openrewrite.rewrite:org.openrewrite.rewrite.gradle.plugin:7.33.0")
}
}
rootProject {
apply plugin: org.openrewrite.gradle.RewritePlugin
rewrite {
activeRecipe("$RECIPE_NAME")
}
dependencies {
rewrite("org.openrewrite.recipe:rewrite-testing-frameworks:2.14.0")
rewrite("org.openrewrite.recipe:rewrite-migrate-java:2.11.0")
rewrite("org.openrewrite.recipe:rewrite-spring:5.7.0")
}
}
allprojects {
apply plugin: org.openrewrite.gradle.RewritePlugin
}
EOF
# Copy the recipe file to the workspace root temporarily so OpenRewrite finds it
cp "$RECIPE_FILE" ./rewrite.yml
echo "Executing OpenRewrite recipe: $RECIPE_NAME"
./gradlew --init-script "$INIT_SCRIPT" rewriteRun --no-parallel --no-configuration-cache
echo "Running code formatters to fix Checkstyle line-length and import ordering..."
./gradlew spotlessApply
# Automatically handle line-wrapping and formatting for all files modified by OpenRewrite
MODIFIED_JAVA_FILES=$(git diff --name-only --diff-filter=d | grep "\.java$" || true)
if [ -n "$MODIFIED_JAVA_FILES" ]; then
echo "Applying google-java-format to all modified Java files to enforce LineLength..."
echo "$MODIFIED_JAVA_FILES" | xargs -r google-java-format --replace
fi
# Clean up temporary files
rm "$INIT_SCRIPT"
rm ./rewrite.yml

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
# Copyright 2026 The Nomulus Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import sys
import re
import os
def usage():
print("Usage: python safe_rename.py <filepath> <old_name> <new_name>")
print("Safely renames an identifier in a Java file, ignoring strings and comments.")
sys.exit(1)
def main():
if len(sys.argv) < 4:
usage()
filepath = sys.argv[1]
old_name = sys.argv[2]
new_name = sys.argv[3]
if not os.path.exists(filepath):
print(f"Error: File {filepath} not found.")
sys.exit(1)
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Regex to tokenize Java source safely.
token_pattern = re.compile(
r'(?P<string>"(?:\\.|[^"\\])*")|'
r'(?P<char>\'(?:\\.|[^\'\\])*\')|'
r'(?P<line_comment>//.*)|'
r'(?P<block_comment>/\*[\s\S]*?\*/)|'
r'(?P<ident>[a-zA-Z_$][a-zA-Z0-9_$]*)'
)
def replacer(match):
if match.group('ident') == old_name:
return new_name
return match.group(0)
new_content = token_pattern.sub(replacer, content)
if content == new_content:
print(f"No occurrences of '{old_name}' found to rename in {filepath}.")
else:
with open(filepath, 'w', encoding='utf-8') as f:
f.write(new_content)
print(f"Successfully renamed '{old_name}' to '{new_name}' in {filepath}.")
if __name__ == '__main__':
main()