Skip to content

Commit 330c6d1

Browse files
timtebeekclaude
andauthored
Dynamic conflict detection for recipe paths (#257)
* Dynamic conflict detection for recipe paths Replace hardcoded special cases for Spring Boot 3.4+ and Hibernate with dynamic conflict detection. Edition suffixes (-moderne-edition, -community-edition) are now added only when both io.moderne and org.openrewrite versions of a recipe exist and would produce the same path. Fixes #256 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Update recipeDescriptors.yml with new doc links --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4315f86 commit 330c6d1

File tree

3 files changed

+230
-150
lines changed

3 files changed

+230
-150
lines changed

src/main/kotlin/org/openrewrite/RecipeMarkdownGenerator.kt

Lines changed: 74 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ class RecipeMarkdownGenerator : Runnable {
122122

123123
println("Found ${allRecipeDescriptors.size} descriptor(s).")
124124

125+
// Detect conflicting paths between io.moderne and org.openrewrite recipes
126+
initializeConflictDetection(allRecipeDescriptors)
127+
125128
val markdownArtifacts = TreeMap<String, MarkdownRecipeArtifact>()
126129
val moderneProprietaryRecipes = TreeMap<String, MutableList<RecipeDescriptor>>()
127130

@@ -239,7 +242,54 @@ class RecipeMarkdownGenerator : Runnable {
239242

240243

241244
companion object {
242-
private val SPRING_BOOT_UPGRADE_PATTERN = Regex("^(io\\.moderne|org\\.openrewrite)\\.java\\.spring\\.boot(\\d+)\\.UpgradeSpringBoot_(\\d+)_(\\d+)$")
245+
// Set of base paths that have both io.moderne and org.openrewrite recipes (conflicts)
246+
private var conflictingBasePaths: Set<String> = emptySet()
247+
248+
/**
249+
* Initialize conflict detection by scanning all recipe descriptors.
250+
* Must be called before any getRecipePath() calls.
251+
*/
252+
fun initializeConflictDetection(allDescriptors: Collection<RecipeDescriptor>) {
253+
val moderneBasePaths = mutableSetOf<String>()
254+
val openrewriteBasePaths = mutableSetOf<String>()
255+
256+
for (descriptor in allDescriptors) {
257+
val name = descriptor.name
258+
when {
259+
name.startsWith("io.moderne") -> {
260+
moderneBasePaths.add(getBasePath(name))
261+
}
262+
name.startsWith("org.openrewrite") -> {
263+
openrewriteBasePaths.add(getBasePath(name))
264+
}
265+
}
266+
}
267+
268+
// Find paths that exist in both sets
269+
conflictingBasePaths = moderneBasePaths.intersect(openrewriteBasePaths)
270+
}
271+
272+
/**
273+
* Compute the base path for a recipe name (without any edition suffix).
274+
* This is used for conflict detection.
275+
*/
276+
private fun getBasePath(recipeName: String): String {
277+
return when {
278+
recipeName.startsWith("org.openrewrite") -> {
279+
if (recipeName.count { it == '.' } == 2) {
280+
"core/" + recipeName.substring(16).lowercase(Locale.getDefault())
281+
} else {
282+
recipeName.substring(16).replace("\\.".toRegex(), "/").lowercase(Locale.getDefault())
283+
}
284+
}
285+
recipeName.startsWith("io.moderne") -> {
286+
recipeName.substring(11).replace("\\.".toRegex(), "/").lowercase(Locale.getDefault())
287+
}
288+
else -> {
289+
recipeName.replace("\\.".toRegex(), "/").lowercase(Locale.getDefault())
290+
}
291+
}
292+
}
243293

244294
/**
245295
* Call Closable.use() together with apply() to avoid adding two levels of indentation
@@ -254,34 +304,24 @@ class RecipeMarkdownGenerator : Runnable {
254304
// Docusaurus expects that if a file is called "assertj" inside of the folder "assertj" that it's the
255305
// README for said folder. Due to how generic we've made this recipe name, we need to change it for the
256306
// docs so that they parse correctly.
257-
fun getRecipePath(recipe: RecipeDescriptor): String =
307+
fun getRecipePath(recipe: RecipeDescriptor): String {
308+
// Check for manual overrides first
258309
if (recipePathToDocusaurusRenamedPath.containsKey(recipe.name)) {
259-
recipePathToDocusaurusRenamedPath[recipe.name]!!
260-
} else if (isSpringBoot34OrHigher(recipe.name)) {
261-
// The moderne and community spring boot recipes clashes with one another (deviating since 3.4) so let's make them distinct
262-
generateSpringBootUpgradePath(recipe.name)
263-
} else if (recipe.name.startsWith("io.moderne.hibernate.")) {
264-
recipe.name
265-
.substring(11)
266-
.replace("\\.".toRegex(), "/")
267-
.lowercase(Locale.getDefault()) + "-moderne-edition"
268-
} else if (recipe.name.startsWith("org.openrewrite.hibernate.")) {
269-
recipe.name
270-
.substring(16)
271-
.replace("\\.".toRegex(), "/")
272-
.lowercase(Locale.getDefault()) + "-community-edition"
273-
} else if (recipe.name.startsWith("org.openrewrite")) {
274-
// If the recipe path only has two periods, it's part of the core recipes and should be adjusted accordingly.
275-
if (recipe.name.count { it == '.' } == 2) {
276-
"core/" + recipe.name
277-
.substring(16)
278-
.lowercase(Locale.getDefault())
279-
} else {
280-
recipe.name.substring(16).replace("\\.".toRegex(), "/").lowercase(Locale.getDefault())
310+
return recipePathToDocusaurusRenamedPath[recipe.name]!!
311+
}
312+
313+
val basePath = getBasePath(recipe.name)
314+
315+
// Add edition suffix only if there's a detected conflict
316+
val needsSuffix = conflictingBasePaths.contains(basePath)
317+
318+
return when {
319+
recipe.name.startsWith("org.openrewrite") -> {
320+
if (needsSuffix) basePath + "-community-edition" else basePath
321+
}
322+
recipe.name.startsWith("io.moderne") -> {
323+
if (needsSuffix) basePath + "-moderne-edition" else basePath
281324
}
282-
} else if (recipe.name.startsWith("io.moderne")) {
283-
recipe.name.substring(11).replace("\\.".toRegex(), "/").lowercase(Locale.getDefault())
284-
} else if (
285325
recipe.name.startsWith("ai.timefold") ||
286326
recipe.name.startsWith("com.google") ||
287327
recipe.name.startsWith("com.oracle") ||
@@ -290,39 +330,21 @@ class RecipeMarkdownGenerator : Runnable {
290330
recipe.name.startsWith("org.apache") ||
291331
recipe.name.startsWith("org.axonframework") ||
292332
recipe.name.startsWith("software.amazon.awssdk") ||
293-
recipe.name.startsWith("tech.picnic")
294-
) {
295-
recipe.name.replace("\\.".toRegex(), "/").lowercase(Locale.getDefault())
296-
} else {
297-
throw RuntimeException("Recipe package unrecognized: ${recipe.name}")
333+
recipe.name.startsWith("tech.picnic") -> {
334+
basePath
335+
}
336+
else -> {
337+
throw RuntimeException("Recipe package unrecognized: ${recipe.name}")
338+
}
298339
}
340+
}
299341

300342
private val recipePathToDocusaurusRenamedPath: Map<String, String> = mapOf(
301343
"org.openrewrite.java.testing.assertj.Assertj" to "java/testing/assertj/assertj-best-practices",
302344
"org.openrewrite.java.migrate.javaee7" to "java/migrate/javaee7-recipe",
303345
"org.openrewrite.java.migrate.javaee8" to "java/migrate/javaee8-recipe"
304346
)
305347

306-
private fun isSpringBoot34OrHigher(recipeName: String): Boolean {
307-
val matchResult = SPRING_BOOT_UPGRADE_PATTERN.find(recipeName) ?: return false
308-
val (_, _, upgradeMajor, upgradeMinor) = matchResult.destructured
309-
val upgradeMajorInt = upgradeMajor.toInt()
310-
val upgradeMinorInt = upgradeMinor.toInt()
311-
return upgradeMajorInt > 3 || (upgradeMajorInt == 3 && upgradeMinorInt >= 4)
312-
}
313-
314-
private fun generateSpringBootUpgradePath(recipeName: String): String {
315-
val matchResult = SPRING_BOOT_UPGRADE_PATTERN.find(recipeName)
316-
317-
return if (matchResult != null) {
318-
val (organization, majorVersion, upgradeMajor, upgradeMinor) = matchResult.destructured
319-
val edition = if (organization == "io.moderne") "moderne-edition" else "community-edition"
320-
"java/spring/boot$majorVersion/upgradespringboot_${upgradeMajor}_$upgradeMinor-$edition"
321-
} else {
322-
throw RuntimeException("Invalid Spring Boot upgrade recipe format: $recipeName")
323-
}
324-
}
325-
326348
@JvmStatic
327349
fun main(args: Array<String>) {
328350
val exitCode = CommandLine(RecipeMarkdownGenerator()).execute(*args)

0 commit comments

Comments
 (0)