From 595439a0cd2258be10c5dd1669e8af7952313b79 Mon Sep 17 00:00:00 2001 From: dragoi75 Date: Mon, 13 Apr 2026 09:30:32 +0200 Subject: [PATCH 01/67] feat: cherry-pick method renaming with overload families --- .../renaming/RenameMethodTransformation.kt | 214 ++++++++++++------ 1 file changed, 149 insertions(+), 65 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 9e14e00..c6a30ef 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -66,13 +66,19 @@ class RenameMethodTransformation( return TransformationResult.Skipped("No matching methods found in ${virtualFile.name}") } - logger.info(" ⏲ Generating rename suggestions for ${publicMethods.size} methods...") + // Group methods into overload families to ensure overloaded methods get the same name + val overloadFamilies = IntelliJAwareTransformation.withReadAction { + groupMethodsByOverloads(publicMethods) + } + + logger.info(" ⏲ Generating rename suggestions for ${publicMethods.size} methods (${overloadFamilies.size} overload families)...") - val renaming = runBlocking { + // Generate suggestions for each overload family (not individual methods) + val familySuggestions = runBlocking { if (useMemory) { - extractRenamesFromMemory(publicMethods, memory, generateWhenNotInMemory) + extractRenamesFromMemoryForFamilies(overloadFamilies, memory, generateWhenNotInMemory) } else { - generateRenames(publicMethods) + generateRenamesForFamilies(overloadFamilies) } } @@ -80,44 +86,74 @@ class RenameMethodTransformation( // or when we POTENTIALLY generated renames for missing entries val saveRenamesInMemory = !useMemory || generateWhenNotInMemory + // Track successful renames across all families + var renamedMethodCount = 0 - // Try renaming each method with suggestions until one succeeds - val successfulRenames = publicMethods.mapNotNull { method -> - val methodName = IntelliJAwareTransformation.withReadAction { method.name } - val suggestions = renaming.suggestions[method] ?: return@mapNotNull null + // Try renaming each overload family + for (family in overloadFamilies) { + val suggestions = familySuggestions[family] ?: continue + val familyName = IntelliJAwareTransformation.withReadAction { family.methodName } - // Generate signature BEFORE renaming - val signature = IntelliJAwareTransformation.withReadAction { - PsiSignatureGenerator.generateSignature(method) - } - if (signature == null) { - logger.warn(" ⊘ Could not generate signature for method $methodName") - return@mapNotNull null + // Generate signatures BEFORE renaming for all methods in the family + val methodSignatures = if (saveRenamesInMemory) { + family.methods.associateWith { method -> + IntelliJAwareTransformation.withReadAction { + PsiSignatureGenerator.generateSignature(method) + } + } + } else { + emptyMap() } - // Try each suggestion until one succeeds (no conflicts) + // Try each suggestion until one succeeds for ALL methods in the family + var familyRenamed = false for (suggestion in suggestions) { - val files = tryRenameMethodAndUsages(psiFile.project, method, suggestion) - if (files != null) { - modifiedFiles.addAll(files) - if (saveRenamesInMemory) { - memory?.put(signature, suggestion) - logger.info(" ✓ Stored rename in memory: `$signature` -> `$suggestion`") + // Skip if suggestion is the same as the original name (no-op rename) + if (suggestion == familyName) { + continue + } + + // Attempt to rename all methods in the family to the same name + val allSucceeded = family.methods.all { method -> + val files = tryRenameMethodAndUsages(psiFile.project, method, suggestion) + if (files != null) { + modifiedFiles.addAll(files) + + // Store in memory if needed (using pre-generated signature) + if (saveRenamesInMemory) { + val signature = methodSignatures[method] + if (signature != null) { + memory?.put(signature, suggestion) + logger.info(" ✓ Stored rename in memory: `$signature` -> `$suggestion`") + } else { + logger.warn(" ⊘ Could not generate signature for method before renaming") + } + } + true + } else { + false } - return@mapNotNull method to suggestion } + + if (allSucceeded) { + renamedMethodCount += family.methods.size + val methodCountInfo = if (family.methods.size > 1) " (${family.methods.size} overloads)" else "" + logger.info(" • Renamed `$familyName` to `$suggestion`$methodCountInfo") + familyRenamed = true + break + } + } + + if (!familyRenamed) { + logger.info(" ⊘ Skipped renaming method $familyName, suggestions: $suggestions") } - // No valid suggestion worked - logger.info(" ⊘ Skipped renaming method `$methodName` (suggestions: $suggestions)") - null } - val renamedCount = successfulRenames.size val totalCandidates = publicMethods.size - val skipped = totalCandidates - renamedCount + val skipped = totalCandidates - renamedMethodCount TransformationResult.Success( - message = "Renamed ${renamedCount}/${totalCandidates} methods in ${virtualFile.name}${if (skipped > 0) " (skipped: $skipped)" else ""}", + message = "Renamed ${renamedMethodCount}/${totalCandidates} methods in ${virtualFile.name}${if (skipped > 0) " (skipped: $skipped)" else ""}", filesModified = modifiedFiles.size ) } else { @@ -142,65 +178,113 @@ class RenameMethodTransformation( ) /** - * Extracts rename suggestions from memory for methods. + * Represents a family of overloaded methods (same name, same containing class). + */ + private data class OverloadFamily( + val methodName: String, + val containingClass: PsiClass, + val methods: List + ) { + /** + * Returns a representative method for generating rename suggestions. + * Prefers methods with bodies (non-abstract) for better context. + */ + fun getRepresentative(): PsiMethod { + return methods.firstOrNull { it.body != null } ?: methods.first() + } + } + + /** + * Groups methods into overload families. + * Methods with the same name in the same containing class are grouped together. + */ + private fun groupMethodsByOverloads(methods: List): List { + val grouped = methods.groupBy { method -> + val className = method.containingClass?.qualifiedName ?: "" + val methodName = method.name + "$className.$methodName" + } + + return grouped.map { (_, methodsInFamily) -> + val representative = methodsInFamily.first() + OverloadFamily( + methodName = representative.name, + containingClass = representative.containingClass!!, + methods = methodsInFamily + ) + } + } + + /** + * Extracts rename suggestions from memory for overload families. + * Returns the same suggestion for all methods in a family. + * Checks ALL methods in the family to find cached names. * * When [generateWhenNotInMemory] is true, generates new suggestions - * for all methods whose suggestions are missing in memory. + * for all families whose suggestions are missing in memory. */ - private suspend fun extractRenamesFromMemory( - methods: List, + private suspend fun extractRenamesFromMemoryForFamilies( + families: List, memory: Memory?, generateWhenNotInMemory: Boolean, - ): Renaming { - val methodsWithMissingSuggestions = mutableListOf() + ): Map> { + val familiesWithMissingSuggestions = mutableListOf() + + val suggestions = families.associateWith { family -> + // Check all methods in the family (not just the representative) + // This handles cases where methods were stored in different orders + for (method in family.methods) { + val signature = IntelliJAwareTransformation.withReadAction { + PsiSignatureGenerator.generateSignature(method) + } - val suggestions = methods.associateWith { method -> - val signature = IntelliJAwareTransformation.withReadAction { - PsiSignatureGenerator.generateSignature(method) - } - if (signature == null) { - logger.warn("Could not generate signature for method") - return@associateWith emptyList() - } + if (signature == null) { + logger.warn("Could not generate signature for method ${family.methodName}") + continue + } - val cachedName = memory?.get(signature) - if (cachedName != null) { - logger.info(" ↳ Using cached rename: $signature -> $cachedName") - listOf(cachedName) - } else { - logger.warn(" ⊘ Signature not found in memory: $signature") - if (generateWhenNotInMemory) { - methodsWithMissingSuggestions.add(method) + val cachedName = memory?.get(signature) + if (cachedName != null) { + logger.info(" ↳ Using cached rename: $signature -> $cachedName") + return@associateWith listOf(cachedName) } - emptyList() } + + // No cached name found for any method in the family + logger.warn(" ⊘ Signature not found in memory: ${family.methodName}") + if (generateWhenNotInMemory) { + familiesWithMissingSuggestions.add(family) + } + emptyList() } - val finalSuggestions = if (generateWhenNotInMemory && methodsWithMissingSuggestions.isNotEmpty()) { - logger.info(" ↳ Generating missing rename suggestions for ${methodsWithMissingSuggestions.size} methods (i.e., generateWhenNotInMemory=true)...") - val generated = generateRenames(methodsWithMissingSuggestions) + val finalSuggestions = if (generateWhenNotInMemory && familiesWithMissingSuggestions.isNotEmpty()) { + logger.info(" ↳ Generating missing rename suggestions for ${familiesWithMissingSuggestions.size} method families (i.e., generateWhenNotInMemory=true)...") + val generated = generateRenamesForFamilies(familiesWithMissingSuggestions) buildMap { - for (method in methods) { - val suggestionsA = suggestions[method] ?: emptyList() - val suggestionsB = generated.suggestions[method] ?: emptyList() - put(method, suggestionsA + suggestionsB) + for (family in families) { + val suggestionsA = suggestions[family] ?: emptyList() + val suggestionsB = generated[family] ?: emptyList() + put(family, suggestionsA + suggestionsB) } } } else { suggestions } - return Renaming(finalSuggestions) + return finalSuggestions } /** - * Generates rename suggestions for all methods using LLM. + * Generates rename suggestions for overload families using LLM. + * Returns the same suggestions for all methods in a family. */ - private suspend fun generateRenames(methods: List): Renaming { - val suggestions = methods.associateWith { method -> - generateNewMethodNames(method) + private suspend fun generateRenamesForFamilies(families: List): Map> { + return families.associateWith { family -> + // Generate suggestions based on the representative method + val representative = family.getRepresentative() + generateNewMethodNames(representative) } - return Renaming(suggestions) } private suspend fun generateNewMethodNames(method: PsiMethod, count: Int = DEFAULT_SUGGESTED_NAMES_SIZE): List { From 6e9c165ace6fcf6dd06582a41466525368330c85 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Thu, 16 Apr 2026 23:28:15 +0200 Subject: [PATCH 02/67] feat: add some changes in `configureCodeStyle` (cannot fully prevent unused import optimization) Left optimizations: 1. Import of a class from the same package. 2. Unused import. Both imports above are removed when references are updated in a file that uses a renamed class/method. --- AGENTS.md | 6 ++++ .../appstarter/HeadlessModeStarter.kt | 31 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ba0a6f5..158125b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,11 @@ CodeCocoon is an IntelliJ Platform plugin for **metamorphic testing** of Java pr - PSI reads require `readAction { }` or `IntelliJAwareTransformation.withReadAction { }` - PSI writes require `writeCommandAction { }` or use self-managed refactoring processors +8. **Import Optimization Prevention**: Code style settings configured to prevent wildcard imports and minimize automatic import modifications + - ✅ Prevents wildcard imports (`import package.*`) + - ✅ Forces single class imports + - ❌ Cannot prevent unused import removal (IntelliJ limitation) + ## Common Tasks - **Adding a transformation**: Implement `IntelliJAwareTransformation`, register in `TransformationRegistry.kt` @@ -46,6 +51,7 @@ CodeCocoon is an IntelliJ Platform plugin for **metamorphic testing** of Java pr ## When to Consult CLAUDE.md Refer to [`CLAUDE.md`](./CLAUDE.md) for: +- **Import optimization prevention** - Detailed explanation of settings and limitations - Detailed architecture explanations - PSI utilities and helper functions - Configuration schema and examples diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt index ea5de4e..23e5275 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt @@ -108,11 +108,20 @@ class HeadlessModeStarter : ApplicationStarter { } /** - * Configures code style settings to prevent wildcard imports. + * Configures code style settings to prevent ALL import optimizations. * - * This sets the import thresholds to very high values (9999) so that imports - * are never collapsed into wildcards (e.g., `import com.example.*`) during - * refactoring operations when `commitAllDocuments()` is called. + * This method configures multiple import-related settings to minimize unwanted + * import modifications during refactoring operations when `commitAllDocuments()` is called. + * + * **Settings configured:** + * 1. Prevents wildcard imports (e.g., `import com.example.*`) + * 2. Forces single class imports + * 3. Disables auto-insertion of inner class imports + * 4. Clears packages that should use wildcards + * + * **Limitations:** + * - Cannot prevent removal of unused imports (hardcoded in IntelliJ's optimize imports) + * - Cannot prevent removal of redundant same-package imports * * **Important:** Call this method once when the project is opened/initialized, * before running any transformations. @@ -123,10 +132,22 @@ class HeadlessModeStarter : ApplicationStarter { val settings = CodeStyle.getSettings(project) val javaSettings = settings.getCustomSettings(JavaCodeStyleSettings::class.java) - // Set thresholds to 9999 to effectively disable wildcard imports + // 1. Set thresholds to 9999 to effectively disable wildcard imports // This prevents IntelliJ from collapsing multiple imports into import com.example.* javaSettings.classCountToUseImportOnDemand = 9999 javaSettings.namesCountToUseImportOnDemand = 9999 + + // 2. Force single class imports (prevent wildcards) + javaSettings.isUseSingleClassImports = true + + // 3. Don't auto-insert inner class imports + javaSettings.isInsertInnerClassImports = false + + logger.info("[CodeCocoon Starter] Configured code style settings:") + logger.info(" - CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND: ${javaSettings.classCountToUseImportOnDemand}") + logger.info(" - NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND: ${javaSettings.namesCountToUseImportOnDemand}") + logger.info(" - USE_SINGLE_CLASS_IMPORTS: ${javaSettings.isUseSingleClassImports}") + logger.info(" - INSERT_INNER_CLASS_IMPORTS: ${javaSettings.isInsertInnerClassImports}") } private fun cleanIdeaFolder(projectPath: String) { From 0589f4794ad355e5ce997067accc435f525361ed Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Thu, 16 Apr 2026 23:42:12 +0200 Subject: [PATCH 03/67] feat: parameterize `searchInComments` of `RenameProcessor` in all renaming transformations --- .../renaming/RenameClassTransformation.kt | 10 +++++++--- .../renaming/RenameMethodTransformation.kt | 10 +++++++--- .../renaming/RenameVariableTransformation.kt | 13 +++++++------ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt index 4bc9aff..cc59d5f 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt @@ -49,6 +49,7 @@ class RenameClassTransformation( val useMemory = config.requireOrDefault("useMemory", defaultValue = false) val generateWhenNotInMemory = config.requireOrDefault("generateWhenNotInMemory", defaultValue = false) val whitelistedAnnotations = config.requireOrDefault>("whitelistedAnnotations", defaultValue = emptyList()) + val searchInComments = config.requireOrDefault("searchInComments", defaultValue = false) val document = withReadAction { psiFile.document() } val modifiedFiles = mutableSetOf() @@ -92,7 +93,7 @@ class RenameClassTransformation( // Try each suggestion until one succeeds for (suggestion in suggestions) { - val files = tryRenameClassAndUsages(psiFile.project, psiClass, suggestion) + val files = tryRenameClassAndUsages(psiFile.project, psiClass, suggestion, searchInComments) if (files != null) { modifiedFiles.addAll(files) if (saveRenamesInMemory) { @@ -253,7 +254,10 @@ class RenameClassTransformation( } private fun tryRenameClassAndUsages( - project: Project, psiClass: PsiClass, newName: String + project: Project, + psiClass: PsiClass, + newName: String, + searchInComments: Boolean, ): MutableSet? { return try { val oldName = psiClass.name ?: return null @@ -263,7 +267,7 @@ class RenameClassTransformation( /* project = */ project, /* element = */ psiClass, /* newName = */ newName, - /* isSearchInComments= */ true, + /* isSearchInComments= */ searchInComments, /* isSearchTextOccurrences = */ false ) } diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index c6a30ef..2705c7e 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -51,6 +51,7 @@ class RenameMethodTransformation( val generateWhenNotInMemory = config.requireOrDefault("generateWhenNotInMemory", defaultValue = false) // list of allowed method annotations, e.g. ["NotNull"] val whitelistedAnnotations = config.requireOrDefault>("whitelistedAnnotations", defaultValue = emptyList()) + val searchInComments = config.requireOrDefault("searchInComments", defaultValue = false) val document = IntelliJAwareTransformation.withReadAction { psiFile.document() } val modifiedFiles = mutableSetOf() @@ -115,7 +116,7 @@ class RenameMethodTransformation( // Attempt to rename all methods in the family to the same name val allSucceeded = family.methods.all { method -> - val files = tryRenameMethodAndUsages(psiFile.project, method, suggestion) + val files = tryRenameMethodAndUsages(psiFile.project, method, suggestion, searchInComments) if (files != null) { modifiedFiles.addAll(files) @@ -337,7 +338,10 @@ class RenameMethodTransformation( } private fun tryRenameMethodAndUsages( - project: Project, method: PsiMethod, newName: String + project: Project, + method: PsiMethod, + newName: String, + searchInComments: Boolean, ): MutableSet? { return try { val oldName = method.name @@ -346,7 +350,7 @@ class RenameMethodTransformation( /* project = */ project, /* element = */ method, /* newName = */ newName, - /* isSearchInComments= */ true, + /* isSearchInComments= */ searchInComments, /* isSearchTextOccurrences = */ false ) } diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt index a331c62..dfa6c64 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt @@ -46,6 +46,7 @@ class RenameVariableTransformation( val result = try { val useMemory = config.requireOrDefault("useMemory", defaultValue = false) val generateWhenNotInMemory = config.requireOrDefault("generateWhenNotInMemory", defaultValue = false) + val searchInComments = config.requireOrDefault("searchInComments", defaultValue = false) val document = withReadAction { psiFile.document() } val modifiedFiles = mutableSetOf() @@ -84,7 +85,7 @@ class RenameVariableTransformation( // Try each suggestion until one succeeds (no conflicts) for (suggestion in suggestions) { - val files = tryRenameVariableAndUsages(psiFile.project, psiVar, suggestion) + val files = tryRenameVariableAndUsages(psiFile.project, psiVar, suggestion, searchInComments) if (files != null) { modifiedFiles.addAll(files) if (saveRenamesInMemory) { @@ -306,18 +307,18 @@ class RenameVariableTransformation( } private fun tryRenameVariableAndUsages( - project: Project, psiVariable: PsiVariable, newName: String + project: Project, + psiVariable: PsiVariable, + newName: String, + searchInComments: Boolean, ): MutableSet? { return try { val oldName = withReadAction { psiVariable.name } ?: return null - // isSearchInComments needs to be false. If true, it would breaks functionality by changing string literals. - // example would be mappings of `PathVariable` from Spring. - // `@param [paramName]` definitions in the Javadocs are still being renamed. val renameProcessor = withReadAction { RenameProcessor( /* project = */ project, /* element = */ psiVariable, /* newName = */ newName, - /* isSearchInComments= */ false, + /* isSearchInComments= */ searchInComments, /* isSearchTextOccurrences = */ false ) } From fc7393bf6e90a8097712e1332e7fef68b7875ae1 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Fri, 17 Apr 2026 22:41:14 +0200 Subject: [PATCH 04/67] feat: print whitelisted annotations and log whether class/method is whitelisted --- .../renaming/RenameClassTransformation.kt | 22 +++++++++++++++++-- .../renaming/RenameMethodTransformation.kt | 22 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt index cc59d5f..b8a3c6b 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt @@ -306,6 +306,13 @@ class RenameClassTransformation( psiFile: PsiFile, whitelistedClassAnnotations: List, ): List { + // Log whitelisted annotations + if (whitelistedClassAnnotations.isNotEmpty()) { + logger.info(" ↳ Whitelisted class annotations: [${whitelistedClassAnnotations.joinToString(", ")}]") + } else { + logger.info(" ↳ No class annotations whitelisted (only non-annotated classes allowed)") + } + val classes = mutableListOf() psiFile.accept(object : PsiRecursiveElementVisitor() { override fun visitElement(element: PsiElement) { @@ -332,8 +339,19 @@ class RenameClassTransformation( if (fileIndex.isInTestSourceContent(psiFile.virtualFile)) return@filter false // either no annotations or whitelisted ones only - val annotationsFilter = cls.annotations.isEmpty() - || cls.annotations.toList().allowedAnnotationsOnly(whitelistedClassAnnotations) + val classAnnotations = cls.annotations.toList() + val annotationsFilter = classAnnotations.isEmpty() + || classAnnotations.allowedAnnotationsOnly(whitelistedClassAnnotations) + + // Log annotation filtering for classes with annotations + if (classAnnotations.isNotEmpty()) { + val annotationNames = classAnnotations.mapNotNull { it.qualifiedName?.substringAfterLast('.') } + if (annotationsFilter) { + logger.info(" ✓ Class `${cls.name}` with annotations [${annotationNames.joinToString(", ")}] - whitelisted") + } else { + logger.info(" ⊘ Class `${cls.name}` with annotations [${annotationNames.joinToString(", ")}] - skipped (not whitelisted)") + } + } // Basic Filters val className = cls.name diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 2705c7e..6197561 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -389,6 +389,13 @@ class RenameMethodTransformation( psiFile: PsiFile, whitelistedMethodAnnotations: List, ): List { + // Log whitelisted annotations + if (whitelistedMethodAnnotations.isNotEmpty()) { + logger.info(" ↳ Whitelisted method annotations: [${whitelistedMethodAnnotations.joinToString(", ")}]") + } else { + logger.info(" ↳ No method annotations whitelisted (only non-annotated methods allowed)") + } + val methods = mutableListOf() psiFile.accept(object : PsiRecursiveElementVisitor() { override fun visitElement(element: PsiElement) { @@ -434,8 +441,19 @@ class RenameMethodTransformation( // Basic Filters // either no method annotations or whitelisted ones only - val annotationsFilter = method.annotations.isEmpty() - || method.annotations.toList().allowedAnnotationsOnly(whitelistedMethodAnnotations) + val methodAnnotations = method.annotations.toList() + val annotationsFilter = methodAnnotations.isEmpty() + || methodAnnotations.allowedAnnotationsOnly(whitelistedMethodAnnotations) + + // Log annotation filtering for methods with annotations + if (methodAnnotations.isNotEmpty()) { + val annotationNames = methodAnnotations.mapNotNull { it.qualifiedName?.substringAfterLast('.') } + if (annotationsFilter) { + logger.info(" ✓ Method `${method.name}` with annotations [${annotationNames.joinToString(", ")}] - whitelisted") + } else { + logger.info(" ⊘ Method `${method.name}` with annotations [${annotationNames.joinToString(", ")}] - skipped (not whitelisted)") + } + } annotationsFilter && !method.isConstructor && From 206d3bfc002d471a410a05cc872edc21defa0f0c Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Fri, 17 Apr 2026 22:54:13 +0200 Subject: [PATCH 05/67] feat: print filter-out reason for classes/methods --- .../renaming/RenameClassTransformation.kt | 25 +++++++-- .../renaming/RenameMethodTransformation.kt | 54 +++++++++++++++---- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt index b8a3c6b..a1e4149 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt @@ -333,10 +333,16 @@ class RenameClassTransformation( val fileType = ref.element.containingFile.fileType.name fileType != "JAVA" && fileType != "Kotlin" } - if (usedInNonJavaFile) return@filter false + if (usedInNonJavaFile) { + logger.info(" ⊘ Class `${cls.name}` - skipped (used in non-Java file)") + return@filter false + } // Is not a test - if (fileIndex.isInTestSourceContent(psiFile.virtualFile)) return@filter false + if (fileIndex.isInTestSourceContent(psiFile.virtualFile)) { + logger.info(" ⊘ Class `${cls.name}` - skipped (in test source)") + return@filter false + } // either no annotations or whitelisted ones only val classAnnotations = cls.annotations.toList() @@ -350,13 +356,26 @@ class RenameClassTransformation( logger.info(" ✓ Class `${cls.name}` with annotations [${annotationNames.joinToString(", ")}] - whitelisted") } else { logger.info(" ⊘ Class `${cls.name}` with annotations [${annotationNames.joinToString(", ")}] - skipped (not whitelisted)") + return@filter false } } // Basic Filters val className = cls.name + + // Check for null class name + if (className == null) { + logger.info(" ⊘ Class - skipped (null class name)") + return@filter false + } + // We need to check for `cls.name.length` > 1 to filter out raw Type classes - (className != null) && (className.length > 1) && annotationsFilter + if (className.length <= 1) { + logger.info(" ⊘ Class `$className` - skipped (class name too short)") + return@filter false + } + + true } if (filteredClasses.isNotEmpty()) { diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 6197561..ad2384d 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -407,7 +407,12 @@ class RenameMethodTransformation( }) val filteredMethods = methods.filter { method -> - val psiClass = method.containingClass ?: return@filter false + val psiClass = method.containingClass + if (psiClass == null) { + logger.info(" ⊘ Method `${method.name}` - skipped (no containing class)") + return@filter false + } + val project = method.project val fileIndex = ProjectFileIndex.getInstance(project) @@ -416,12 +421,18 @@ class RenameMethodTransformation( val extendsLibraryInterface = psiClass.supers.any { superInterface -> superInterface.containingFile?.virtualFile?.let { fileIndex.isInLibrary(it) } == true } - if (extendsLibraryInterface) return@filter false + if (extendsLibraryInterface) { + logger.info(" ⊘ Method `${method.name}` - skipped (interface extends library interface)") + return@filter false + } } // Inheritance Guard: // Catch methods that override methods - if (method.findSuperMethods().isNotEmpty()) return@filter false + if (method.findSuperMethods().isNotEmpty()) { + logger.info(" ⊘ Method `${method.name}` - skipped (overrides super method)") + return@filter false + } // Non-Code Usage Guard val references = ReferencesSearch.search(method).findAll() @@ -429,15 +440,22 @@ class RenameMethodTransformation( val fileType = ref.element.containingFile.fileType.name fileType != "JAVA" && fileType != "Kotlin" } - if (usedInNonJavaFile) return@filter false + if (usedInNonJavaFile) { + logger.info(" ⊘ Method `${method.name}` - skipped (used in non-Java file)") + return@filter false + } // Public API Guard if (method.hasModifierProperty(PsiModifier.PUBLIC) && references.isEmpty()) { + logger.info(" ⊘ Method `${method.name}` - skipped (public API with no references)") return@filter false } // Is not a test - if (fileIndex.isInTestSourceContent(psiFile.virtualFile)) return@filter false + if (fileIndex.isInTestSourceContent(psiFile.virtualFile)) { + logger.info(" ⊘ Method `${method.name}` - skipped (in test source)") + return@filter false + } // Basic Filters // either no method annotations or whitelisted ones only @@ -452,15 +470,29 @@ class RenameMethodTransformation( logger.info(" ✓ Method `${method.name}` with annotations [${annotationNames.joinToString(", ")}] - whitelisted") } else { logger.info(" ⊘ Method `${method.name}` with annotations [${annotationNames.joinToString(", ")}] - skipped (not whitelisted)") + return@filter false } } - annotationsFilter && - !method.isConstructor && - method.name !in DISALLOWED_METHOD_NAMES && - !method.name.startsWith("get") && - !method.name.startsWith("set") && - !method.name.startsWith("is") + // Constructor check + if (method.isConstructor) { + logger.info(" ⊘ Method `${method.name}` - skipped (is constructor)") + return@filter false + } + + // Disallowed method names + if (method.name in DISALLOWED_METHOD_NAMES) { + logger.info(" ⊘ Method `${method.name}` - skipped (disallowed method name)") + return@filter false + } + + // Getter/setter/is prefix check + if (method.name.startsWith("get") || method.name.startsWith("set") || method.name.startsWith("is")) { + logger.info(" ⊘ Method `${method.name}` - skipped (getter/setter/is prefix)") + return@filter false + } + + true } if (filteredMethods.isNotEmpty()) { From eccbd4224ae9d23acd1353305e66d03c6d21ac79 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Fri, 17 Apr 2026 23:33:23 +0200 Subject: [PATCH 06/67] feat: filter out method overload families + check when super class is `java.lang.Object` (don't filter) --- .../renaming/RenameMethodTransformation.kt | 259 +++++++++++------- 1 file changed, 157 insertions(+), 102 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index ad2384d..2ac3e25 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -56,23 +56,20 @@ class RenameMethodTransformation( val document = IntelliJAwareTransformation.withReadAction { psiFile.document() } val modifiedFiles = mutableSetOf() val value = if (document != null) { - val publicMethods: List = IntelliJAwareTransformation.withReadAction { - findAllValidMethods( + // Find all valid method families (already grouped and filtered) + val overloadFamilies: List = IntelliJAwareTransformation.withReadAction { + findAllValidMethodFamilies( psiFile = psiFile, whitelistedMethodAnnotations = whitelistedAnnotations ) } - if (publicMethods.isEmpty()) { - return TransformationResult.Skipped("No matching methods found in ${virtualFile.name}") + if (overloadFamilies.isEmpty()) { + return TransformationResult.Skipped("No matching method families found in ${virtualFile.name}") } - // Group methods into overload families to ensure overloaded methods get the same name - val overloadFamilies = IntelliJAwareTransformation.withReadAction { - groupMethodsByOverloads(publicMethods) - } - - logger.info(" ⏲ Generating rename suggestions for ${publicMethods.size} methods (${overloadFamilies.size} overload families)...") + val totalMethods = overloadFamilies.sumOf { it.methods.size } + logger.info(" ⏲ Generating rename suggestions for $totalMethods methods (${overloadFamilies.size} overload families)...") // Generate suggestions for each overload family (not individual methods) val familySuggestions = runBlocking { @@ -150,11 +147,10 @@ class RenameMethodTransformation( } } - val totalCandidates = publicMethods.size - val skipped = totalCandidates - renamedMethodCount + val skipped = totalMethods - renamedMethodCount TransformationResult.Success( - message = "Renamed ${renamedMethodCount}/${totalCandidates} methods in ${virtualFile.name}${if (skipped > 0) " (skipped: $skipped)" else ""}", + message = "Renamed ${renamedMethodCount}/${totalMethods} methods in ${virtualFile.name}${if (skipped > 0) " (skipped: $skipped)" else ""}", filesModified = modifiedFiles.size ) } else { @@ -382,20 +378,9 @@ class RenameMethodTransformation( } /** - * @param psiFile The PSI file to search for methods - * @param whitelistedMethodAnnotations A list of method annotations that are allowed to be present on the method. + * Collects all methods from the PSI file without any filtering. */ - private fun findAllValidMethods( - psiFile: PsiFile, - whitelistedMethodAnnotations: List, - ): List { - // Log whitelisted annotations - if (whitelistedMethodAnnotations.isNotEmpty()) { - logger.info(" ↳ Whitelisted method annotations: [${whitelistedMethodAnnotations.joinToString(", ")}]") - } else { - logger.info(" ↳ No method annotations whitelisted (only non-annotated methods allowed)") - } - + private fun collectAllMethods(psiFile: PsiFile): List { val methods = mutableListOf() psiFile.accept(object : PsiRecursiveElementVisitor() { override fun visitElement(element: PsiElement) { @@ -405,102 +390,172 @@ class RenameMethodTransformation( } } }) + return methods + } - val filteredMethods = methods.filter { method -> - val psiClass = method.containingClass - if (psiClass == null) { - logger.info(" ⊘ Method `${method.name}` - skipped (no containing class)") - return@filter false - } - - val project = method.project - val fileIndex = ProjectFileIndex.getInstance(project) + /** + * Checks if a single method passes all filtering criteria. + * Returns true if the method should be included, false otherwise. + */ + private fun passesMethodFilters( + method: PsiMethod, + psiFile: PsiFile, + whitelistedMethodAnnotations: List + ): Boolean { + val psiClass = method.containingClass + if (psiClass == null) { + logger.info(" ⊘ Method `${method.name}` - skipped (no containing class)") + return false + } - // If our interface extends a library interface, skip it. - if (psiClass.isInterface) { - val extendsLibraryInterface = psiClass.supers.any { superInterface -> - superInterface.containingFile?.virtualFile?.let { fileIndex.isInLibrary(it) } == true - } - if (extendsLibraryInterface) { - logger.info(" ⊘ Method `${method.name}` - skipped (interface extends library interface)") - return@filter false + val project = method.project + val fileIndex = ProjectFileIndex.getInstance(project) + + // If our interface extends a library interface, skip it. + // FIX: Filter out java.lang.Object which is implicitly extended by all interfaces + if (psiClass.isInterface) { + val extendsLibraryInterface = psiClass.supers.any { superInterface -> + val qualifiedName = superInterface.qualifiedName + // Skip java.lang.Object (implicitly extended by all interfaces) + if (qualifiedName == "java.lang.Object") { + return@any false } + superInterface.containingFile?.virtualFile?.let { fileIndex.isInLibrary(it) } == true } - - // Inheritance Guard: - // Catch methods that override methods - if (method.findSuperMethods().isNotEmpty()) { - logger.info(" ⊘ Method `${method.name}` - skipped (overrides super method)") - return@filter false + if (extendsLibraryInterface) { + logger.info(" ⊘ Method `${method.name}` - skipped (interface extends library interface)") + return false } + } - // Non-Code Usage Guard - val references = ReferencesSearch.search(method).findAll() - val usedInNonJavaFile = references.any { ref -> - val fileType = ref.element.containingFile.fileType.name - fileType != "JAVA" && fileType != "Kotlin" - } - if (usedInNonJavaFile) { - logger.info(" ⊘ Method `${method.name}` - skipped (used in non-Java file)") - return@filter false - } + // Inheritance Guard: + // Catch methods that override methods + if (method.findSuperMethods().isNotEmpty()) { + logger.info(" ⊘ Method `${method.name}` - skipped (overrides super method)") + return false + } - // Public API Guard - if (method.hasModifierProperty(PsiModifier.PUBLIC) && references.isEmpty()) { - logger.info(" ⊘ Method `${method.name}` - skipped (public API with no references)") - return@filter false - } + // Non-Code Usage Guard + val references = ReferencesSearch.search(method).findAll() + val usedInNonJavaFile = references.any { ref -> + val fileType = ref.element.containingFile.fileType.name + fileType != "JAVA" && fileType != "Kotlin" + } + if (usedInNonJavaFile) { + logger.info(" ⊘ Method `${method.name}` - skipped (used in non-Java file)") + return false + } - // Is not a test - if (fileIndex.isInTestSourceContent(psiFile.virtualFile)) { - logger.info(" ⊘ Method `${method.name}` - skipped (in test source)") - return@filter false - } + // Public API Guard + if (method.hasModifierProperty(PsiModifier.PUBLIC) && references.isEmpty()) { + logger.info(" ⊘ Method `${method.name}` - skipped (public API with no references)") + return false + } - // Basic Filters - // either no method annotations or whitelisted ones only - val methodAnnotations = method.annotations.toList() - val annotationsFilter = methodAnnotations.isEmpty() - || methodAnnotations.allowedAnnotationsOnly(whitelistedMethodAnnotations) - - // Log annotation filtering for methods with annotations - if (methodAnnotations.isNotEmpty()) { - val annotationNames = methodAnnotations.mapNotNull { it.qualifiedName?.substringAfterLast('.') } - if (annotationsFilter) { - logger.info(" ✓ Method `${method.name}` with annotations [${annotationNames.joinToString(", ")}] - whitelisted") - } else { - logger.info(" ⊘ Method `${method.name}` with annotations [${annotationNames.joinToString(", ")}] - skipped (not whitelisted)") - return@filter false - } - } + // Is not a test + if (fileIndex.isInTestSourceContent(psiFile.virtualFile)) { + logger.info(" ⊘ Method `${method.name}` - skipped (in test source)") + return false + } - // Constructor check - if (method.isConstructor) { - logger.info(" ⊘ Method `${method.name}` - skipped (is constructor)") - return@filter false + // Basic Filters + // either no method annotations or whitelisted ones only + val methodAnnotations = method.annotations.toList() + val annotationsFilter = methodAnnotations.isEmpty() + || methodAnnotations.allowedAnnotationsOnly(whitelistedMethodAnnotations) + + // Log annotation filtering for methods with annotations + if (methodAnnotations.isNotEmpty()) { + val annotationNames = methodAnnotations.mapNotNull { it.qualifiedName?.substringAfterLast('.') } + if (annotationsFilter) { + logger.info(" ✓ Method `${method.name}` with annotations [${annotationNames.joinToString(", ")}] - whitelisted") + } else { + logger.info(" ⊘ Method `${method.name}` with annotations [${annotationNames.joinToString(", ")}] - skipped (not whitelisted)") + return false } + } - // Disallowed method names - if (method.name in DISALLOWED_METHOD_NAMES) { - logger.info(" ⊘ Method `${method.name}` - skipped (disallowed method name)") - return@filter false + // Constructor check + if (method.isConstructor) { + logger.info(" ⊘ Method `${method.name}` - skipped (is constructor)") + return false + } + + // Disallowed method names + if (method.name in DISALLOWED_METHOD_NAMES) { + logger.info(" ⊘ Method `${method.name}` - skipped (disallowed method name)") + return false + } + + // Getter/setter/is prefix check + if (method.name.startsWith("get") || method.name.startsWith("set") || method.name.startsWith("is")) { + logger.info(" ⊘ Method `${method.name}` - skipped (getter/setter/is prefix)") + return false + } + + return true + } + + /** + * Filters overload families where ALL methods in the family pass the filters. + * If any method in a family fails a filter, the entire family is excluded. + */ + private fun filterValidFamilies( + families: List, + psiFile: PsiFile, + whitelistedMethodAnnotations: List + ): List { + return families.filter { family -> + // Check if ALL methods in the family pass filters + val allMethodsValid = family.methods.all { method -> + passesMethodFilters(method, psiFile, whitelistedMethodAnnotations) } - // Getter/setter/is prefix check - if (method.name.startsWith("get") || method.name.startsWith("set") || method.name.startsWith("is")) { - logger.info(" ⊘ Method `${method.name}` - skipped (getter/setter/is prefix)") - return@filter false + if (!allMethodsValid) { + logger.info(" ⊘ Overload family `${family.methodName}` (${family.methods.size} methods) - skipped (one or more methods filtered out)") } - true + allMethodsValid } + } - if (filteredMethods.isNotEmpty()) { - // prettify filepath attempting to make it relative to the project root + /** + * Finds all valid method families in the PSI file. + * Returns overload families where ALL methods pass filtering criteria. + * + * @param psiFile The PSI file to search for methods + * @param whitelistedMethodAnnotations A list of method annotations that are allowed to be present on the method. + * @return List of valid overload families + */ + private fun findAllValidMethodFamilies( + psiFile: PsiFile, + whitelistedMethodAnnotations: List, + ): List { + // Log whitelisted annotations + if (whitelistedMethodAnnotations.isNotEmpty()) { + logger.info(" ↳ Whitelisted method annotations: ${whitelistedMethodAnnotations.joinToString(", ")}") + } else { + logger.info(" ↳ No method annotations whitelisted (only non-annotated methods allowed)") + } + + // Step 1: Collect all methods without filtering + val allMethods = collectAllMethods(psiFile) + + // Step 2: Group into overload families + val allFamilies = groupMethodsByOverloads(allMethods) + + logger.info(" ↳ Found ${allMethods.size} total methods in ${allFamilies.size} overload families") + + // Step 3: Filter families (all methods in family must pass) + val validFamilies = filterValidFamilies(allFamilies, psiFile, whitelistedMethodAnnotations) + + if (validFamilies.isNotEmpty()) { + val validMethodCount = validFamilies.sumOf { it.methods.size } val filepath = psiFile.virtualFile?.let { psiFile.project.relativeToRootOrAbsPath(it) } ?: "" - logger.info(" ↳ Found ${filteredMethods.size} matching methods in '$filepath'") + logger.info(" ↳ After filtering: ${validFamilies.size} valid families with $validMethodCount methods in '$filepath'") } - return filteredMethods + + return validFamilies } companion object { From 6f37f26632868d8b25c17904f6e40075b4ed7a24 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Fri, 17 Apr 2026 23:33:54 +0200 Subject: [PATCH 07/67] feat: remove Public API Guard filter (allow public API with no refs be renamed) --- .../transformations/renaming/RenameMethodTransformation.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 2ac3e25..df1d089 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -446,12 +446,6 @@ class RenameMethodTransformation( return false } - // Public API Guard - if (method.hasModifierProperty(PsiModifier.PUBLIC) && references.isEmpty()) { - logger.info(" ⊘ Method `${method.name}` - skipped (public API with no references)") - return false - } - // Is not a test if (fileIndex.isInTestSourceContent(psiFile.virtualFile)) { logger.info(" ⊘ Method `${method.name}` - skipped (in test source)") From 3ed461fad57f077a93b9eaf6d7cd6a2c131d396e Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Fri, 17 Apr 2026 23:49:13 +0200 Subject: [PATCH 08/67] feat: print override methods in log --- .../transformations/renaming/RenameMethodTransformation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index df1d089..6d3e059 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -431,7 +431,7 @@ class RenameMethodTransformation( // Inheritance Guard: // Catch methods that override methods if (method.findSuperMethods().isNotEmpty()) { - logger.info(" ⊘ Method `${method.name}` - skipped (overrides super method)") + logger.info(" ⊘ Method `${method.name}` - skipped (overrides super method in [${method.findSuperMethods().joinToString { it.name }}])") return false } From 0031db88a853b813bc1745c65865d01817d64322 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Fri, 17 Apr 2026 23:56:51 +0200 Subject: [PATCH 09/67] feat: separate static and instance methods in overload grouping and filter overrides before grouping --- .../renaming/RenameMethodTransformation.kt | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 6d3e059..795b16d 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -194,12 +194,15 @@ class RenameMethodTransformation( /** * Groups methods into overload families. * Methods with the same name in the same containing class are grouped together. + * Static and instance methods are kept in separate families even if they share the same name. */ private fun groupMethodsByOverloads(methods: List): List { val grouped = methods.groupBy { method -> - val className = method.containingClass?.qualifiedName ?: "" - val methodName = method.name - "$className.$methodName" + Triple( + method.containingClass?.qualifiedName ?: "", + method.name, + method.hasModifierProperty(PsiModifier.STATIC) + ) } return grouped.map { (_, methodsInFamily) -> @@ -428,12 +431,8 @@ class RenameMethodTransformation( } } - // Inheritance Guard: - // Catch methods that override methods - if (method.findSuperMethods().isNotEmpty()) { - logger.info(" ⊘ Method `${method.name}` - skipped (overrides super method in [${method.findSuperMethods().joinToString { it.name }}])") - return false - } + // Note: Override check is now handled in findAllValidMethodFamilies() BEFORE grouping + // This prevents override methods from contaminating overload families with static methods // Non-Code Usage Guard val references = ReferencesSearch.search(method).findAll() @@ -535,12 +534,26 @@ class RenameMethodTransformation( // Step 1: Collect all methods without filtering val allMethods = collectAllMethods(psiFile) - // Step 2: Group into overload families - val allFamilies = groupMethodsByOverloads(allMethods) + // Step 2: Filter out override methods BEFORE grouping + // Override methods must keep their original names to maintain inheritance contracts + val nonOverrideMethods = allMethods.filter { method -> + val superMethods = method.findSuperMethods() + if (superMethods.isNotEmpty()) { + logger.info(" ⊘ Method `${method.name}` - skipped (overrides super method from ${superMethods.firstOrNull()?.containingClass?.qualifiedName})") + false + } else { + true + } + } + + logger.info(" ↳ Found ${allMethods.size} total methods, ${nonOverrideMethods.size} non-override methods") + + // Step 3: Group into overload families (now without overrides) + val allFamilies = groupMethodsByOverloads(nonOverrideMethods) - logger.info(" ↳ Found ${allMethods.size} total methods in ${allFamilies.size} overload families") + logger.info(" ↳ Grouped into ${allFamilies.size} overload families (static/instance separate)") - // Step 3: Filter families (all methods in family must pass) + // Step 4: Filter families (all methods in family must pass remaining filters) val validFamilies = filterValidFamilies(allFamilies, psiFile, whitelistedMethodAnnotations) if (validFamilies.isNotEmpty()) { From 4e1caa0ba825cef37dcdbe53540e9a19362e5407 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 18 Apr 2026 00:08:18 +0200 Subject: [PATCH 10/67] feat: log overload family details in `RenameMethodTransformation` --- .../renaming/RenameMethodTransformation.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 795b16d..88cac3e 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -553,6 +553,37 @@ class RenameMethodTransformation( logger.info(" ↳ Grouped into ${allFamilies.size} overload families (static/instance separate)") + // Print family details + if (allFamilies.isNotEmpty()) { + logger.info(" ↳ Overload families found:") + for (family in allFamilies) { + val className = family.containingClass.qualifiedName ?: family.containingClass.name ?: "" + val isStatic = family.methods.firstOrNull()?.hasModifierProperty(PsiModifier.STATIC) + val modifier = when (isStatic) { + null -> "unknown" + true -> "static" + else -> "instance" + } + logger.info(" • $className.${family.methodName} [$modifier, ${family.methods.size} overload(s)]:") + + val signatures = IntelliJAwareTransformation.withReadAction { + family.methods.mapNotNull { method -> + PsiSignatureGenerator.generateSignature(method) + } + } + + val displayLimit = 10 + signatures.take(displayLimit).forEach { signature -> + logger.info(" $signature") + } + + if (signatures.size > displayLimit) { + val remaining = signatures.size - displayLimit + logger.info(" ... ($remaining more, ${signatures.size} total)") + } + } + } + // Step 4: Filter families (all methods in family must pass remaining filters) val validFamilies = filterValidFamilies(allFamilies, psiFile, whitelistedMethodAnnotations) From 688d959d981758e0dce9018e69155024e3e76f65 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 18 Apr 2026 00:35:32 +0200 Subject: [PATCH 11/67] feat: group overload families by class and improve logging in `RenameMethodTransformation` --- .../renaming/RenameMethodTransformation.kt | 173 ++++++++++-------- 1 file changed, 100 insertions(+), 73 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 88cac3e..fcac978 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -87,63 +87,75 @@ class RenameMethodTransformation( // Track successful renames across all families var renamedMethodCount = 0 - // Try renaming each overload family - for (family in overloadFamilies) { - val suggestions = familySuggestions[family] ?: continue - val familyName = IntelliJAwareTransformation.withReadAction { family.methodName } - - // Generate signatures BEFORE renaming for all methods in the family - val methodSignatures = if (saveRenamesInMemory) { - family.methods.associateWith { method -> - IntelliJAwareTransformation.withReadAction { - PsiSignatureGenerator.generateSignature(method) - } - } - } else { - emptyMap() + // Group families by class for organized logging + val familiesByClass = overloadFamilies.groupBy { + IntelliJAwareTransformation.withReadAction { + it.containingClass.qualifiedName ?: it.containingClass.name ?: "" } + } - // Try each suggestion until one succeeds for ALL methods in the family - var familyRenamed = false - for (suggestion in suggestions) { - // Skip if suggestion is the same as the original name (no-op rename) - if (suggestion == familyName) { - continue + logger.info(" ↳ Renaming methods in ${familiesByClass.size} class(es)...") + + // Try renaming each overload family, grouped by class + for ((className, classFamilies) in familiesByClass) { + logger.info(" ◆ Processing class: `$className` (${classFamilies.size} overload families):") + + for ((familyIndex, family) in classFamilies.withIndex()) { + val suggestions = familySuggestions[family] ?: continue + val familyName = IntelliJAwareTransformation.withReadAction { family.methodName } + + // Generate signatures BEFORE renaming for all methods in the family + val methodSignatures = if (saveRenamesInMemory) { + family.methods.associateWith { method -> + IntelliJAwareTransformation.withReadAction { + PsiSignatureGenerator.generateSignature(method) + } + } + } else { + emptyMap() } - // Attempt to rename all methods in the family to the same name - val allSucceeded = family.methods.all { method -> - val files = tryRenameMethodAndUsages(psiFile.project, method, suggestion, searchInComments) - if (files != null) { - modifiedFiles.addAll(files) - - // Store in memory if needed (using pre-generated signature) - if (saveRenamesInMemory) { - val signature = methodSignatures[method] - if (signature != null) { - memory?.put(signature, suggestion) - logger.info(" ✓ Stored rename in memory: `$signature` -> `$suggestion`") - } else { - logger.warn(" ⊘ Could not generate signature for method before renaming") + // Try each suggestion until one succeeds for ALL methods in the family + var familyRenamed = false + for (suggestion in suggestions) { + // Skip if suggestion is the same as the original name (no-op rename) + if (suggestion == familyName) { + continue + } + + // Attempt to rename all methods in the family to the same name + logger.info(" • ${familyIndex + 1}) Renaming `$familyName` overload family to `$suggestion` (${family.methods.size} overloads):") + val allSucceeded = family.methods.all { method -> + val files = tryRenameMethodAndUsages(psiFile.project, method, suggestion, searchInComments) + if (files != null) { + modifiedFiles.addAll(files) + + // Store in memory if needed (using pre-generated signature) + if (saveRenamesInMemory) { + val signature = methodSignatures[method] + if (signature != null) { + memory?.put(signature, suggestion) + logger.info(" ✓ Stored rename in memory: `$signature` -> `$suggestion`") + } else { + logger.warn(" ⊘ Could not generate signature for method before renaming") + } } + true + } else { + false } - true - } else { - false } - } - if (allSucceeded) { - renamedMethodCount += family.methods.size - val methodCountInfo = if (family.methods.size > 1) " (${family.methods.size} overloads)" else "" - logger.info(" • Renamed `$familyName` to `$suggestion`$methodCountInfo") - familyRenamed = true - break + if (allSucceeded) { + renamedMethodCount += family.methods.size + familyRenamed = true + break + } } - } - if (!familyRenamed) { - logger.info(" ⊘ Skipped renaming method $familyName, suggestions: $suggestions") + if (!familyRenamed) { + logger.info(" ⊘ Skipped renaming method `$familyName`, suggestions: $suggestions") + } } } @@ -367,7 +379,7 @@ class RenameMethodTransformation( method.containingFile?.let { files.add(it) } files } - logger.info(" • Renamed `$oldName` to `$newName` in ${modifiedFiles.size} files") + logger.info(" • Renamed `$oldName` to `$newName` in ${modifiedFiles.size} files") modifiedFiles } catch (e: ProcessCanceledException) { // Must rethrow control flow exceptions @@ -375,7 +387,7 @@ class RenameMethodTransformation( throw e } catch (e: Exception) { // Rename failed (conflicts, PSI errors, etc.) - return null to try the next suggestion - logger.info(" • Skipped ${method.name}:\n (Reason: ${e.message})") + logger.info(" ⊘ Skipped ${method.name}:\n (Reason: ${e.message})") null } } @@ -539,7 +551,10 @@ class RenameMethodTransformation( val nonOverrideMethods = allMethods.filter { method -> val superMethods = method.findSuperMethods() if (superMethods.isNotEmpty()) { - logger.info(" ⊘ Method `${method.name}` - skipped (overrides super method from ${superMethods.firstOrNull()?.containingClass?.qualifiedName})") + val signature = IntelliJAwareTransformation.withReadAction { + PsiSignatureGenerator.generateSignature(method) + } + logger.info(" ⊘ Method `${method.name}` ($signature) - skipped (overrides super method from `${superMethods.firstOrNull()?.containingClass?.qualifiedName}`)") false } else { true @@ -551,35 +566,47 @@ class RenameMethodTransformation( // Step 3: Group into overload families (now without overrides) val allFamilies = groupMethodsByOverloads(nonOverrideMethods) - logger.info(" ↳ Grouped into ${allFamilies.size} overload families (static/instance separate)") + // Analyze classes involved + val uniqueClasses = allFamilies.map { it.containingClass }.distinctBy { it.qualifiedName } + val classCount = uniqueClasses.size + + logger.info(" ↳ Grouped into ${allFamilies.size} overload families from $classCount class(es) (static/instance separate)") - // Print family details + // Print family details grouped by class if (allFamilies.isNotEmpty()) { - logger.info(" ↳ Overload families found:") - for (family in allFamilies) { - val className = family.containingClass.qualifiedName ?: family.containingClass.name ?: "" - val isStatic = family.methods.firstOrNull()?.hasModifierProperty(PsiModifier.STATIC) - val modifier = when (isStatic) { - null -> "unknown" - true -> "static" - else -> "instance" - } - logger.info(" • $className.${family.methodName} [$modifier, ${family.methods.size} overload(s)]:") + logger.info(" ↳ Overload families by class:") + + // Group families by containing class + val familiesByClass = allFamilies.groupBy { it.containingClass.qualifiedName ?: it.containingClass.name ?: "" } - val signatures = IntelliJAwareTransformation.withReadAction { - family.methods.mapNotNull { method -> - PsiSignatureGenerator.generateSignature(method) + for ((className, families) in familiesByClass) { + val totalMethods = families.sumOf { it.methods.size } + logger.info(" ◆ Class: $className (${families.size} families, $totalMethods methods)") + + for (family in families) { + val isStatic = family.methods.firstOrNull()?.hasModifierProperty(PsiModifier.STATIC) + val modifier = when (isStatic) { + null -> "unknown" + true -> "static" + else -> "instance" } - } + logger.info(" • ${family.methodName} [$modifier, ${family.methods.size} overload(s)]:") - val displayLimit = 10 - signatures.take(displayLimit).forEach { signature -> - logger.info(" $signature") - } + val signatures = IntelliJAwareTransformation.withReadAction { + family.methods.mapNotNull { method -> + PsiSignatureGenerator.generateSignature(method) + } + } - if (signatures.size > displayLimit) { - val remaining = signatures.size - displayLimit - logger.info(" ... ($remaining more, ${signatures.size} total)") + val displayLimit = 10 + signatures.take(displayLimit).forEach { signature -> + logger.info(" $signature") + } + + if (signatures.size > displayLimit) { + val remaining = signatures.size - displayLimit + logger.info(" ... ($remaining more, ${signatures.size} total)") + } } } } From 19d74a123311f1b8683e5a06e0921515d703ff50 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 18 Apr 2026 01:00:49 +0200 Subject: [PATCH 12/67] feat: add annotation filtering mode (whitelist/blacklist) in `RenameMethodTransformation` --- .../renaming/RenameMethodTransformation.kt | 193 ++++++++++++++++-- 1 file changed, 173 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index fcac978..13c9ada 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -49,10 +49,18 @@ class RenameMethodTransformation( val result = try { val useMemory = config.requireOrDefault("useMemory", defaultValue = false) val generateWhenNotInMemory = config.requireOrDefault("generateWhenNotInMemory", defaultValue = false) - // list of allowed method annotations, e.g. ["NotNull"] - val whitelistedAnnotations = config.requireOrDefault>("whitelistedAnnotations", defaultValue = emptyList()) val searchInComments = config.requireOrDefault("searchInComments", defaultValue = false) + // Annotation filtering configuration + val whitelistedAnnotations = config.requireOrDefault>("whitelistedAnnotations", defaultValue = emptyList()) + val blacklistedAnnotations = config.requireOrDefault>("blacklistedAnnotations", defaultValue = emptyList()) + + // Auto-detect mode: if whitelistedAnnotations is provided, use whitelist mode; otherwise blacklist + val annotationFilterMode = config.requireOrDefault( + "annotationFilterMode", + defaultValue = if (whitelistedAnnotations.isNotEmpty()) "whitelist" else "blacklist" + ) + val document = IntelliJAwareTransformation.withReadAction { psiFile.document() } val modifiedFiles = mutableSetOf() val value = if (document != null) { @@ -60,7 +68,9 @@ class RenameMethodTransformation( val overloadFamilies: List = IntelliJAwareTransformation.withReadAction { findAllValidMethodFamilies( psiFile = psiFile, - whitelistedMethodAnnotations = whitelistedAnnotations + annotationFilterMode = annotationFilterMode, + whitelistedMethodAnnotations = whitelistedAnnotations, + blacklistedMethodAnnotations = blacklistedAnnotations ) } @@ -392,6 +402,50 @@ class RenameMethodTransformation( } } + /** + * Checks if annotations pass the configured filter mode (whitelist or blacklist). + * + * @param annotations List of annotations to check + * @param filterMode "whitelist" or "blacklist" + * @param whitelistedAnnotations Annotations to allow (when mode = whitelist) + * @param blacklistedAnnotations Annotations to forbid (when mode = blacklist) + * @return true if annotations pass the filter, false otherwise + */ + private fun passesAnnotationFilter( + annotations: List, + filterMode: String, + whitelistedAnnotations: List, + blacklistedAnnotations: List, + ): Boolean { + if (annotations.isEmpty()) { + return true + } + + return when (filterMode.lowercase()) { + "whitelist" -> { + // All annotations must be in the whitelist + annotations.all { annotation -> + val qualifiedName = annotation.qualifiedName + val simpleName = qualifiedName?.substringAfterLast('.') + (qualifiedName != null) && (qualifiedName in whitelistedAnnotations || simpleName in whitelistedAnnotations) + } + } + "blacklist" -> { + // No annotations can be in the blacklist + annotations.none { annotation -> + val qualifiedName = annotation.qualifiedName + val simpleName = qualifiedName?.substringAfterLast('.') + qualifiedName in blacklistedAnnotations || simpleName in blacklistedAnnotations + } + } + else -> { + logger.warn(" ⚠ Unknown annotation filter mode: '$filterMode', defaulting to blacklist") + // Default to blacklist mode with empty list (allow all) + true + } + } + } + /** * Collects all methods from the PSI file without any filtering. */ @@ -415,7 +469,9 @@ class RenameMethodTransformation( private fun passesMethodFilters( method: PsiMethod, psiFile: PsiFile, - whitelistedMethodAnnotations: List + annotationFilterMode: String, + whitelistedMethodAnnotations: List, + blacklistedMethodAnnotations: List, ): Boolean { val psiClass = method.containingClass if (psiClass == null) { @@ -463,19 +519,24 @@ class RenameMethodTransformation( return false } - // Basic Filters - // either no method annotations or whitelisted ones only + // Annotation filter val methodAnnotations = method.annotations.toList() - val annotationsFilter = methodAnnotations.isEmpty() - || methodAnnotations.allowedAnnotationsOnly(whitelistedMethodAnnotations) + val annotationsPass = passesAnnotationFilter( + methodAnnotations, + annotationFilterMode, + whitelistedMethodAnnotations, + blacklistedMethodAnnotations + ) // Log annotation filtering for methods with annotations if (methodAnnotations.isNotEmpty()) { val annotationNames = methodAnnotations.mapNotNull { it.qualifiedName?.substringAfterLast('.') } - if (annotationsFilter) { - logger.info(" ✓ Method `${method.name}` with annotations [${annotationNames.joinToString(", ")}] - whitelisted") + if (annotationsPass) { + val modeText = if (annotationFilterMode == "whitelist") "whitelisted" else "allowed" + logger.info(" ✓ Method `${method.name}` with annotations [${annotationNames.joinToString(", ")}] - $modeText") } else { - logger.info(" ⊘ Method `${method.name}` with annotations [${annotationNames.joinToString(", ")}] - skipped (not whitelisted)") + val modeText = if (annotationFilterMode == "whitelist") "not whitelisted" else "blacklisted" + logger.info(" ⊘ Method `${method.name}` with annotations [${annotationNames.joinToString(", ")}] - skipped ($modeText)") return false } } @@ -508,12 +569,20 @@ class RenameMethodTransformation( private fun filterValidFamilies( families: List, psiFile: PsiFile, - whitelistedMethodAnnotations: List + annotationFilterMode: String, + whitelistedMethodAnnotations: List, + blacklistedMethodAnnotations: List ): List { return families.filter { family -> // Check if ALL methods in the family pass filters val allMethodsValid = family.methods.all { method -> - passesMethodFilters(method, psiFile, whitelistedMethodAnnotations) + passesMethodFilters( + method, + psiFile, + annotationFilterMode, + whitelistedMethodAnnotations, + blacklistedMethodAnnotations, + ) } if (!allMethodsValid) { @@ -529,18 +598,34 @@ class RenameMethodTransformation( * Returns overload families where ALL methods pass filtering criteria. * * @param psiFile The PSI file to search for methods - * @param whitelistedMethodAnnotations A list of method annotations that are allowed to be present on the method. + * @param annotationFilterMode "whitelist" or "blacklist" + * @param whitelistedMethodAnnotations A list of method annotations that are allowed (whitelist mode) + * @param blacklistedMethodAnnotations A list of method annotations that are forbidden (blacklist mode) * @return List of valid overload families */ private fun findAllValidMethodFamilies( psiFile: PsiFile, + annotationFilterMode: String, whitelistedMethodAnnotations: List, + blacklistedMethodAnnotations: List, ): List { - // Log whitelisted annotations - if (whitelistedMethodAnnotations.isNotEmpty()) { - logger.info(" ↳ Whitelisted method annotations: ${whitelistedMethodAnnotations.joinToString(", ")}") - } else { - logger.info(" ↳ No method annotations whitelisted (only non-annotated methods allowed)") + // Log annotation filter configuration + logger.info(" ↳ Annotation filter mode: $annotationFilterMode") + when (annotationFilterMode.lowercase()) { + "whitelist" -> { + if (whitelistedMethodAnnotations.isNotEmpty()) { + logger.info(" ↳ Whitelisted method annotations: ${whitelistedMethodAnnotations.joinToString(", ")}") + } else { + logger.info(" ↳ Whitelist mode active with empty list (only non-annotated methods allowed)") + } + } + "blacklist" -> { + if (blacklistedMethodAnnotations.isNotEmpty()) { + logger.info(" ↳ Blacklisted method annotations: ${blacklistedMethodAnnotations.joinToString(", ")}") + } else { + logger.info(" ↳ Blacklist mode active with empty list (all annotations allowed)") + } + } } // Step 1: Collect all methods without filtering @@ -612,7 +697,13 @@ class RenameMethodTransformation( } // Step 4: Filter families (all methods in family must pass remaining filters) - val validFamilies = filterValidFamilies(allFamilies, psiFile, whitelistedMethodAnnotations) + val validFamilies = filterValidFamilies( + allFamilies, + psiFile, + annotationFilterMode, + whitelistedMethodAnnotations, + blacklistedMethodAnnotations, + ) if (validFamilies.isNotEmpty()) { val validMethodCount = validFamilies.sumOf { it.methods.size } @@ -631,6 +722,68 @@ class RenameMethodTransformation( "clone", "finalize", "wait", "notify", "notifyAll" ) + /** + * Default blacklisted method annotations (framework/infrastructure annotations). + * These annotations typically indicate methods that are called by frameworks/containers, + * so renaming them could break runtime behavior. + */ + val DEFAULT_BLACKLISTED_METHOD_ANNOTATIONS = setOf( + // JPA/Hibernate Lifecycle + "javax.persistence.PrePersist", + "javax.persistence.PostPersist", + "javax.persistence.PreUpdate", + "javax.persistence.PostUpdate", + "javax.persistence.PreRemove", + "javax.persistence.PostRemove", + "javax.persistence.PostLoad", + + // Spring Framework + "org.springframework.web.bind.annotation.RequestMapping", + "org.springframework.web.bind.annotation.GetMapping", + "org.springframework.web.bind.annotation.PostMapping", + "org.springframework.web.bind.annotation.PutMapping", + "org.springframework.web.bind.annotation.DeleteMapping", + "org.springframework.web.bind.annotation.PatchMapping", + "org.springframework.transaction.annotation.Transactional", + "org.springframework.scheduling.annotation.Scheduled", + "org.springframework.cache.annotation.Cacheable", + "org.springframework.cache.annotation.CacheEvict", + "org.springframework.context.event.EventListener", + "org.springframework.jmx.export.annotation.ManagedOperation", + + // JAX-RS (REST APIs) + "javax.ws.rs.GET", + "javax.ws.rs.POST", + "javax.ws.rs.PUT", + "javax.ws.rs.DELETE", + "javax.ws.rs.Path", + "jakarta.ws.rs.GET", + "jakarta.ws.rs.POST", + "jakarta.ws.rs.PUT", + "jakarta.ws.rs.DELETE", + "jakarta.ws.rs.Path", + + // Jackson (JSON) + "com.fasterxml.jackson.annotation.JsonGetter", + "com.fasterxml.jackson.annotation.JsonSetter", + "com.fasterxml.jackson.annotation.JsonProperty", + "com.fasterxml.jackson.annotation.JsonCreator", + + // JavaFX/Swing + "javafx.fxml.FXML", + + // JUnit/Testing Lifecycle + "org.junit.jupiter.api.BeforeEach", + "org.junit.jupiter.api.AfterEach", + "org.junit.jupiter.api.BeforeAll", + "org.junit.jupiter.api.AfterAll", + "org.junit.Test", + "org.junit.Before", + "org.junit.After", + "org.junit.BeforeClass", + "org.junit.AfterClass" + ) + private const val DEFAULT_SUGGESTED_NAMES_SIZE = 5 } } \ No newline at end of file From 100637996b4c51f11ea26f393552de31290e35b9 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 18 Apr 2026 01:00:57 +0200 Subject: [PATCH 13/67] feat: add annotation filtering mode (whitelist/blacklist) in `RenameClassTransformation` --- .../renaming/RenameClassTransformation.kt | 148 ++++++++++++++++-- 1 file changed, 136 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt index a1e4149..1255dcc 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt @@ -48,16 +48,27 @@ class RenameClassTransformation( val result = try { val useMemory = config.requireOrDefault("useMemory", defaultValue = false) val generateWhenNotInMemory = config.requireOrDefault("generateWhenNotInMemory", defaultValue = false) - val whitelistedAnnotations = config.requireOrDefault>("whitelistedAnnotations", defaultValue = emptyList()) val searchInComments = config.requireOrDefault("searchInComments", defaultValue = false) + // Annotation filtering configuration + val whitelistedAnnotations = config.requireOrDefault>("whitelistedAnnotations", defaultValue = emptyList()) + val blacklistedAnnotations = config.requireOrDefault>("blacklistedAnnotations", defaultValue = emptyList()) + + // Auto-detect mode: if whitelistedAnnotations is provided, use whitelist mode; otherwise blacklist + val annotationFilterMode = config.requireOrDefault( + "annotationFilterMode", + defaultValue = if (whitelistedAnnotations.isNotEmpty()) "whitelist" else "blacklist" + ) + val document = withReadAction { psiFile.document() } val modifiedFiles = mutableSetOf() val value = if (document != null) { val eligibleClasses: List = withReadAction { findAllValidClasses( psiFile = psiFile, + annotationFilterMode = annotationFilterMode, whitelistedClassAnnotations = whitelistedAnnotations, + blacklistedClassAnnotations = blacklistedAnnotations, ) } @@ -304,13 +315,31 @@ class RenameClassTransformation( */ private fun findAllValidClasses( psiFile: PsiFile, + annotationFilterMode: String, whitelistedClassAnnotations: List, + blacklistedClassAnnotations: List, ): List { - // Log whitelisted annotations - if (whitelistedClassAnnotations.isNotEmpty()) { - logger.info(" ↳ Whitelisted class annotations: [${whitelistedClassAnnotations.joinToString(", ")}]") - } else { - logger.info(" ↳ No class annotations whitelisted (only non-annotated classes allowed)") + // Log annotation filter mode and relevant annotations + when (annotationFilterMode.lowercase()) { + "whitelist" -> { + if (whitelistedClassAnnotations.isNotEmpty()) { + logger.info(" ↳ Annotation filter mode: WHITELIST") + logger.info(" ↳ Whitelisted class annotations: [${whitelistedClassAnnotations.joinToString(", ")}]") + } else { + logger.info(" ↳ Annotation filter mode: WHITELIST (empty - only non-annotated classes allowed)") + } + } + "blacklist" -> { + logger.info(" ↳ Annotation filter mode: BLACKLIST") + if (blacklistedClassAnnotations.isNotEmpty()) { + logger.info(" ↳ Blacklisted class annotations: [${blacklistedClassAnnotations.joinToString(", ")}]") + } else { + logger.info(" ↳ Blacklisted class annotations: [] (all annotations allowed)") + } + } + else -> { + logger.warn(" ⚠ Unknown annotation filter mode: '$annotationFilterMode', defaulting to blacklist") + } } val classes = mutableListOf() @@ -344,18 +373,24 @@ class RenameClassTransformation( return@filter false } - // either no annotations or whitelisted ones only + // Check annotation filter (whitelist or blacklist mode) val classAnnotations = cls.annotations.toList() - val annotationsFilter = classAnnotations.isEmpty() - || classAnnotations.allowedAnnotationsOnly(whitelistedClassAnnotations) + val annotationsPassed = passesAnnotationFilter( + annotations = classAnnotations, + filterMode = annotationFilterMode, + whitelistedAnnotations = whitelistedClassAnnotations, + blacklistedAnnotations = blacklistedClassAnnotations + ) // Log annotation filtering for classes with annotations if (classAnnotations.isNotEmpty()) { val annotationNames = classAnnotations.mapNotNull { it.qualifiedName?.substringAfterLast('.') } - if (annotationsFilter) { - logger.info(" ✓ Class `${cls.name}` with annotations [${annotationNames.joinToString(", ")}] - whitelisted") + if (annotationsPassed) { + val modeLabel = if (annotationFilterMode.lowercase() == "whitelist") "whitelisted" else "passed blacklist" + logger.info(" ✓ Class `${cls.name}` with annotations [${annotationNames.joinToString(", ")}] - $modeLabel") } else { - logger.info(" ⊘ Class `${cls.name}` with annotations [${annotationNames.joinToString(", ")}] - skipped (not whitelisted)") + val modeLabel = if (annotationFilterMode.lowercase() == "whitelist") "not whitelisted" else "blacklisted" + logger.info(" ⊘ Class `${cls.name}` with annotations [${annotationNames.joinToString(", ")}] - skipped ($modeLabel)") return@filter false } } @@ -386,8 +421,97 @@ class RenameClassTransformation( return filteredClasses } + /** + * Checks if annotations pass the configured filter mode (whitelist or blacklist). + * + * @param annotations List of annotations to check + * @param filterMode "whitelist" or "blacklist" + * @param whitelistedAnnotations Annotations to allow (when mode = whitelist) + * @param blacklistedAnnotations Annotations to forbid (when mode = blacklist) + * @return true if annotations pass the filter, false otherwise + */ + private fun passesAnnotationFilter( + annotations: List, + filterMode: String, + whitelistedAnnotations: List, + blacklistedAnnotations: List, + ): Boolean { + if (annotations.isEmpty()) { + return true + } + + return when (filterMode.lowercase()) { + "whitelist" -> { + // All annotations must be in the whitelist + annotations.all { annotation -> + val qualifiedName = annotation.qualifiedName + val simpleName = qualifiedName?.substringAfterLast('.') + (qualifiedName != null) && (qualifiedName in whitelistedAnnotations || simpleName in whitelistedAnnotations) + } + } + "blacklist" -> { + // No annotations can be in the blacklist + annotations.none { annotation -> + val qualifiedName = annotation.qualifiedName + val simpleName = qualifiedName?.substringAfterLast('.') + qualifiedName in blacklistedAnnotations || simpleName in blacklistedAnnotations + } + } + else -> { + logger.warn(" ⚠ Unknown annotation filter mode: '$filterMode', defaulting to blacklist") + // Default to blacklist mode with empty list (allow all) + true + } + } + } + companion object { const val ID = "rename-class-transformation" + + /** + * Default blacklisted class annotations (framework/infrastructure annotations). + * These annotations typically indicate classes that are managed by frameworks/containers, + * so renaming them could break runtime behavior or configuration. + */ + val DEFAULT_BLACKLISTED_CLASS_ANNOTATIONS = setOf( + // JPA/Hibernate + "javax.persistence.Entity", + "javax.persistence.Table", + "javax.persistence.Embeddable", + "javax.persistence.MappedSuperclass", + + // Spring Framework + "org.springframework.stereotype.Component", + "org.springframework.stereotype.Service", + "org.springframework.stereotype.Repository", + "org.springframework.stereotype.Controller", + "org.springframework.web.bind.annotation.RestController", + "org.springframework.web.bind.annotation.ControllerAdvice", + "org.springframework.context.annotation.Configuration", + "org.springframework.boot.autoconfigure.SpringBootApplication", + "org.springframework.jmx.export.annotation.ManagedResource", + + // JAX-RS + "javax.ws.rs.Path", + "jakarta.ws.rs.Path", + + // CDI + "javax.inject.Named", + "jakarta.inject.Named", + "javax.enterprise.context.ApplicationScoped", + "javax.enterprise.context.RequestScoped", + "javax.enterprise.context.SessionScoped", + + // Jackson + "com.fasterxml.jackson.annotation.JsonRootName", + + // JAXB + "javax.xml.bind.annotation.XmlRootElement", + "javax.xml.bind.annotation.XmlType", + "jakarta.xml.bind.annotation.XmlRootElement", + "jakarta.xml.bind.annotation.XmlType" + ) + private const val DEFAULT_SUGGESTED_NAMES_SIZE = 3 } } \ No newline at end of file From 52c75a1b0acbe7c22541ab36f2a44b96f0016046 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 18 Apr 2026 01:11:44 +0200 Subject: [PATCH 14/67] feat: support default blacklist merging and improve blacklist logging in `RenameMethodTransformation` --- .../renaming/RenameMethodTransformation.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 13c9ada..1ebfe84 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -53,7 +53,21 @@ class RenameMethodTransformation( // Annotation filtering configuration val whitelistedAnnotations = config.requireOrDefault>("whitelistedAnnotations", defaultValue = emptyList()) - val blacklistedAnnotations = config.requireOrDefault>("blacklistedAnnotations", defaultValue = emptyList()) + val blacklistedAnnotationsRaw = config.requireOrDefault>("blacklistedAnnotations", defaultValue = emptyList()) + + // Process blacklist: merge defaults if "_default" or "default" is present + val blacklistedAnnotations = if (blacklistedAnnotationsRaw.any { it.equals("_default", ignoreCase = true) || it.equals("default", ignoreCase = true) }) { + logger.info(" ↳ Include default blacklisted annotations ALONG with the custom ones (i.e., '_default' or 'default' keyword in the list)") + + val customAnnotations = blacklistedAnnotationsRaw.filter { !it.equals("_default", ignoreCase = true) && !it.equals("default", ignoreCase = true) } + (DEFAULT_BLACKLISTED_METHOD_ANNOTATIONS + customAnnotations).toList() + } else { + // Warn if using blacklist mode without defaults + if (blacklistedAnnotationsRaw.isNotEmpty()) { + logger.warn(" ⚠ Blacklist provided without '_default' keyword - framework annotations will NOT be automatically excluded") + } + blacklistedAnnotationsRaw + } // Auto-detect mode: if whitelistedAnnotations is provided, use whitelist mode; otherwise blacklist val annotationFilterMode = config.requireOrDefault( @@ -621,7 +635,7 @@ class RenameMethodTransformation( } "blacklist" -> { if (blacklistedMethodAnnotations.isNotEmpty()) { - logger.info(" ↳ Blacklisted method annotations: ${blacklistedMethodAnnotations.joinToString(", ")}") + logger.info(" ↳ Blacklisted method annotations: [\n${blacklistedMethodAnnotations.joinToString(",\n") { "\t$it" } }\n]") } else { logger.info(" ↳ Blacklist mode active with empty list (all annotations allowed)") } From 36f9f571b332cfc5e5d8a8dbd520821ea1f2c9b0 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 18 Apr 2026 01:11:51 +0200 Subject: [PATCH 15/67] feat: support default blacklist merging and improve blacklist logging in `RenameClassTransformation` --- .../renaming/RenameClassTransformation.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt index 1255dcc..bf40557 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt @@ -52,7 +52,21 @@ class RenameClassTransformation( // Annotation filtering configuration val whitelistedAnnotations = config.requireOrDefault>("whitelistedAnnotations", defaultValue = emptyList()) - val blacklistedAnnotations = config.requireOrDefault>("blacklistedAnnotations", defaultValue = emptyList()) + val blacklistedAnnotationsRaw = config.requireOrDefault>("blacklistedAnnotations", defaultValue = emptyList()) + + // Process blacklist: merge defaults if "_default" or "default" is present + val blacklistedAnnotations = if (blacklistedAnnotationsRaw.any { it.equals("_default", ignoreCase = true) || it.equals("default", ignoreCase = true) }) { + logger.info(" ↳ Include default blacklisted annotations ALONG with the custom ones (i.e., '_default' or 'default' keyword in the list)") + + val customAnnotations = blacklistedAnnotationsRaw.filter { !it.equals("_default", ignoreCase = true) && !it.equals("default", ignoreCase = true) } + (DEFAULT_BLACKLISTED_CLASS_ANNOTATIONS + customAnnotations).toList() + } else { + // Warn if using blacklist mode without defaults + if (blacklistedAnnotationsRaw.isNotEmpty()) { + logger.warn(" ⚠ Blacklist provided without '_default' keyword - framework annotations will NOT be automatically excluded") + } + blacklistedAnnotationsRaw + } // Auto-detect mode: if whitelistedAnnotations is provided, use whitelist mode; otherwise blacklist val annotationFilterMode = config.requireOrDefault( @@ -332,7 +346,7 @@ class RenameClassTransformation( "blacklist" -> { logger.info(" ↳ Annotation filter mode: BLACKLIST") if (blacklistedClassAnnotations.isNotEmpty()) { - logger.info(" ↳ Blacklisted class annotations: [${blacklistedClassAnnotations.joinToString(", ")}]") + logger.info(" ↳ Blacklisted class annotations: [\n${blacklistedClassAnnotations.joinToString(",\n") { "\t$it" } }\n]") } else { logger.info(" ↳ Blacklisted class annotations: [] (all annotations allowed)") } From 2a7088759ba76e6196608e8c76dabd6b0afbbd6d Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 18 Apr 2026 01:54:44 +0200 Subject: [PATCH 16/67] feat: update readme and agents md files --- AGENTS.md | 27 +++++--- README.md | 180 +++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 184 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 158125b..ec2c81b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,23 +20,34 @@ CodeCocoon is an IntelliJ Platform plugin for **metamorphic testing** of Java pr - Self-managed: `SelfManagedTransformation` (uses refactoring processors) 3. **Built-in transformations**: - - `rename-method-transformation` - Rename methods via LLM suggestions - - `rename-class-transformation` - Rename classes - - `rename-variable-transformation` - Rename fields/parameters/locals + - `rename-method-transformation` - Rename methods via LLM suggestions (supports annotation whitelist/blacklist) + - `rename-class-transformation` - Rename classes via LLM suggestions (supports annotation whitelist/blacklist) + - `rename-variable-transformation` - Rename fields/parameters/locals via LLM suggestions - `move-file-into-suggested-directory-transformation/ai` - Move files (LLM suggests destination) - `move-file-into-suggested-directory-transformation/config` - Move files (config specifies destination) + - `add-comment-transformation` - Example transformation (adds comment to file start) 4. **LLM Integration**: Uses Grazie/Koog to generate semantically similar names -5. **Memory System**: Caches LLM suggestions in `.codecocoon-memory/` to avoid redundant API calls +5. **Memory System**: + - Persistent cache storing LLM suggestions in `.codecocoon-memory/.json` + - Signature-based: Each element (class/method/variable/file) gets unique signature + - Controlled via `useMemory` and `generateWhenNotInMemory` config options + - Auto-saves on transformation completion via `PersistentMemory.use {}` -6. **Configuration**: `codecocoon.yml` in project root defines transformations and target files +6. **Annotation Filtering** (methods/classes only): + - **Whitelist mode**: Only rename elements WITH specified annotations + - **Blacklist mode** (recommended): Rename all EXCEPT those with specified annotations + - **`"_default"` keyword**: Merges 40+ framework annotations (Spring, JPA, JAX-RS, JUnit, etc.) with custom ones + - Warning logged if blacklist used without `"_default"` -7. **Important Threading Rules**: +7. **Configuration**: `codecocoon.yml` in project root defines transformations and target files + +8. **Important Threading Rules**: - PSI reads require `readAction { }` or `IntelliJAwareTransformation.withReadAction { }` - PSI writes require `writeCommandAction { }` or use self-managed refactoring processors -8. **Import Optimization Prevention**: Code style settings configured to prevent wildcard imports and minimize automatic import modifications +9. **Import Optimization Prevention**: Code style settings configured to prevent wildcard imports and minimize automatic import modifications - ✅ Prevents wildcard imports (`import package.*`) - ✅ Forces single class imports - ❌ Cannot prevent unused import removal (IntelliJ limitation) @@ -56,6 +67,8 @@ Refer to [`CLAUDE.md`](./CLAUDE.md) for: - PSI utilities and helper functions - Configuration schema and examples - Transformation implementation details +- Memory system internals (`PsiSignatureGenerator`, signature format) +- Annotation filtering implementation (whitelist/blacklist logic) - Error handling patterns - File structure overview - Dependencies and testing diff --git a/README.md b/README.md index 21225e3..bbd617c 100644 --- a/README.md +++ b/README.md @@ -33,22 +33,170 @@ transformations: - "src/main" ``` -## Built-in transformations - -- Rename Method (id: `rename-method-transformation`) - - Renames Java methods to an LLM-suggested, semantically similar name and updates usages/overrides. - - Skips: overrides, tests, interface methods extending library interfaces, annotated methods, constructors, toString/get*/set*/is*, public methods with no refs, and methods referenced from non-Java/Kotlin files. -- Rename Class (id: `rename-class-transformation`) - - Renames Java classes to an LLM-suggested, semantically similar name and updates usages/overrides. - - Skips: classes referenced from non-Java files, test class names and annotated classes. -- Rename Variable (id: `rename-variable-transformation`) - - Renames Java variables to an LLM-suggested, semantically similar name and updates usages. - - Skips: variables in test classes, enums, and those declared in library-files. -- Move File Into Directory Suggested By AI (id: `move-file-into-suggested-directory-transformation/ai`) - - Moves a Java file into another directory suggested by an LLM based on the file content. -- Move File Into Directory From Config (id: `move-file-into-suggested-directory-transformation/config`) - - Moves a Java file into a directory provided in the config under `destination` entry. - - The directory MUST be within the project BUT may be either new or existing. +## Memory System + +CodeCocoon includes a **persistent memory system** that caches LLM-generated suggestions to avoid redundant API calls and ensure consistency across runs. Memory is stored in `.codecocoon-memory/` directory as JSON files, one per project. + +**Key features:** +- Signature-based caching: Each renamed element (class/method/variable) gets a unique signature +- Automatic persistence: Memory is saved automatically when transformations complete +- Reusability: Run the same transformation multiple times without re-querying the LLM +- Optional generation: Configure whether to generate new suggestions for missing entries + +All renaming transformations support memory via `useMemory` and `generateWhenNotInMemory` config options. + +## Built-in Transformations + +### 1. Rename Method (`rename-method-transformation`) + +Renames Java methods to LLM-suggested, semantically similar names and updates all usages/overrides. Processes methods in **overload families** to ensure consistency. + +**Filters (methods are skipped if):** +- Override super methods +- In test sources +- In interfaces extending library interfaces +- Belong to library classes +- Are constructors or Object methods (equals, hashCode, toString, etc.) +- Match excluded patterns (toString, get*, set*, is*) +- Have no public references +- Referenced from non-Java/Kotlin files +- Fail annotation filter (whitelist/blacklist mode) + +**Configuration:** +```yaml +- id: "rename-method-transformation" + config: + # Memory configuration + useMemory: true # Optional, default: false. Use cached suggestions + generateWhenNotInMemory: true # Optional, default: false. Generate if not cached + searchInComments: false # Optional, default: false. Rename in comments too + + # Annotation filtering (choose whitelist OR blacklist mode) + annotationFilterMode: "blacklist" # Optional, default: "blacklist" if blacklistedAnnotations non-empty, else "whitelist" + + # Blacklist mode (recommended): Rename all methods EXCEPT those with these annotations + blacklistedAnnotations: + - "_default" # Special keyword: includes 40+ framework annotations (Spring, JPA, JAX-RS, JUnit, etc.) + - "MyCustomAnnotation" # Add your own annotations + + # Whitelist mode: Only rename methods WITH these annotations + whitelistedAnnotations: + - "SuppressWarnings" + - "Deprecated" +``` + +**Annotation filter modes:** +- **Blacklist** (recommended): Rename everything EXCEPT framework-managed methods. Use `"_default"` to include all standard framework annotations (Spring `@RequestMapping`, JPA `@PrePersist`, JAX-RS `@GET`/`@POST`, JUnit `@Test`/`@BeforeEach`, etc.) plus custom ones. +- **Whitelist**: Only rename methods with specific annotations. Empty whitelist = only non-annotated methods. +- **⚠ Warning**: Omitting `"_default"` in blacklist mode will NOT exclude framework annotations automatically. + +--- + +### 2. Rename Class (`rename-class-transformation`) + +Renames Java classes to LLM-suggested, semantically similar names and updates all usages. + +**Filters (classes are skipped if):** +- Referenced from non-Java files +- In test sources +- Class name is null or ≤1 character +- Fail annotation filter (whitelist/blacklist mode) + +**Configuration:** +```yaml +- id: "rename-class-transformation" + config: + # Memory configuration + useMemory: true # Optional, default: false + generateWhenNotInMemory: true # Optional, default: false + searchInComments: false # Optional, default: false + + # Annotation filtering (choose whitelist OR blacklist mode) + annotationFilterMode: "blacklist" # Optional, default: "blacklist" if blacklistedAnnotations non-empty, else "whitelist" + + # Blacklist mode (recommended): Rename all classes EXCEPT those with these annotations + blacklistedAnnotations: + - "_default" # Special keyword: includes 25+ framework annotations (JPA, Spring, JAX-RS, JAXB, etc.) + - "MyCustomAnnotation" + + # Whitelist mode: Only rename classes WITH these annotations + whitelistedAnnotations: + - "Deprecated" +``` + +**Annotation filter modes:** Same as rename-method (see above). Default blacklist includes JPA `@Entity`/`@Table`, Spring `@Component`/`@Service`/`@Controller`, JAX-RS `@Path`, JAXB `@XmlRootElement`, etc. + +--- + +### 3. Rename Variable (`rename-variable-transformation`) + +Renames Java variables (fields, parameters, locals) to LLM-suggested, semantically similar names and updates all usages. + +**Filters (variables are skipped if):** +- In test sources +- Enum constants +- Annotated with `@Column` +- Declared in library/compiled code +- Public/protected fields (to avoid breaking external consumers) + +**Configuration:** +```yaml +- id: "rename-variable-transformation" + config: + useMemory: true # Optional, default: false + generateWhenNotInMemory: true # Optional, default: false + searchInComments: false # Optional, default: false +``` + +**Note:** Variable renaming does NOT support annotation filtering. + +--- + +### 4. Move File (AI-Suggested) (`move-file-into-suggested-directory-transformation/ai`) + +Moves Java files into directories suggested by an LLM based on file content and project structure. + +**Filters (files are skipped if):** +- Not a Java file +- In test sources +- Contains package-local classes used by other files (would break compilation) + +**Configuration:** +```yaml +- id: "move-file-into-suggested-directory-transformation/ai" + config: + useMemory: true # Optional, default: null (no memory) + generateWhenNotInMemory: true # Optional, default: false + maxAgentIterations: 60 # Optional, default: 50. Max LLM iterations for directory search +``` + +--- + +### 5. Move File (Config-Specified) (`move-file-into-suggested-directory-transformation/config`) + +Moves Java files into a specific directory provided in the configuration. + +**Configuration:** +```yaml +- id: "move-file-into-suggested-directory-transformation/config" + config: + destination: "src/main/java/services/impl" # Required. Absolute or relative to project root. Can be new or existing. +``` + +**Note:** This transformation does NOT use memory (destination is explicit). + +--- + +### 6. Add Comment (`add-comment-transformation`) + +**Example transformation** that adds a comment at the beginning of a file. Not for production use. + +**Configuration:** +```yaml +- id: "add-comment-transformation" + config: + message: "This file was transformed" # Required. Comment text (without "//" prefix) +``` ## Template ToDo list - [x] Create a new [IntelliJ Platform Plugin Template][template] project. From 2754449e8e4af4f245aca610980637ff0f3d93d3 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 18 Apr 2026 16:58:45 +0200 Subject: [PATCH 17/67] feat: extract variable filtering logic into a dedicated `passesVariableFilters` method in `RenameVariableTransformation` --- .../renaming/RenameVariableTransformation.kt | 72 ++++++++++++------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt index dfa6c64..a532e26 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt @@ -351,6 +351,51 @@ class RenameVariableTransformation( } } + /** + * Checks if a single variable passes all filtering criteria. + * Returns true if the variable should be included, false otherwise. + */ + private fun passesVariableFilters( + variable: PsiVariable, + psiFile: PsiFile, + ): Boolean { + val fileIndex = ProjectFileIndex.getInstance(psiFile.project) + + // 1. Exclude Test Sources + if (fileIndex.isInTestSourceContent(psiFile.virtualFile)) { + logger.info(" ⊘ Variable `${variable.name}` - skipped (in test source)") + return false + } + + // 2. Exclude Enum Constants + if (variable is PsiEnumConstant) { + logger.info(" ⊘ Variable `${variable.name}` - skipped (is enum constant)") + return false + } + + // 3. Exclude @Column annotated variables + if (variable.annotations.any { it.qualifiedName?.contains("Column") == true }) { + logger.info(" ⊘ Variable `${variable.name}` - skipped (has @Column annotation)") + return false + } + + // 4. Exclude Library/Compiled Code + if (variable is PsiCompiledElement || !variable.isPhysical) { + logger.info(" ⊘ Variable `${variable.name}` - skipped (compiled or non-physical)") + return false + } + + // 5. Exclude public/protected fields (could cause external breaking changes) + if (variable is PsiField) { + if (variable.hasModifierProperty(PsiModifier.PUBLIC) || variable.hasModifierProperty(PsiModifier.PROTECTED)) { + logger.info(" ⊘ Variable `${variable.name}` - skipped (public/protected field)") + return false + } + } + + return true + } + /** * Identifies and filters valid variables from the provided PSI file based on specific criteria. * The filtering logic excludes variables in test sources, enum constants, variables annotated with `@Column`, @@ -371,33 +416,8 @@ class RenameVariableTransformation( } }) - val fileIndex = ProjectFileIndex.getInstance(psiFile.project) - val filteredVariables = variables.filter { v -> - // 1. Exclude Test Sources - if (fileIndex.isInTestSourceContent(psiFile.virtualFile)) return@filter false - - // 2. Exclude Enum Constants - if (v is PsiEnumConstant) return@filter false - - // 3. Exclude @Column annotated variables - if (v.annotations.any { it.qualifiedName?.contains("Column") == true }) return@filter false - - // 4. Exclude Library/Compiled Code - if (v !is PsiCompiledElement && v.isPhysical) { - // 5. Overrides Check (for fields/parameters) - // If a field overrides a superclass field, renaming it might break polymorphism or hide fields. - // Simple heuristic: Only rename private/package-private fields or local vars to stay safe. - if (v is PsiField) { - if (v.hasModifierProperty(PsiModifier.PUBLIC) || v.hasModifierProperty(PsiModifier.PROTECTED)) { - // Skip public/protected fields to avoid breaking external consumers or overrides - return@filter false - } - } - true - } else { - false - } + passesVariableFilters(v, psiFile) } if (filteredVariables.isNotEmpty()) { From 396e3ce38074cb5a42ca957c835ff357e96c9f77 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 18 Apr 2026 17:11:00 +0200 Subject: [PATCH 18/67] feat: add annotation filtering (blacklist mode) to `RenameVariableTransformation` and update documentation --- AGENTS.md | 15 +- README.md | 13 +- .../renaming/RenameVariableTransformation.kt | 154 +++++++++++++++++- 3 files changed, 167 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ec2c81b..d952901 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ CodeCocoon is an IntelliJ Platform plugin for **metamorphic testing** of Java pr 3. **Built-in transformations**: - `rename-method-transformation` - Rename methods via LLM suggestions (supports annotation whitelist/blacklist) - `rename-class-transformation` - Rename classes via LLM suggestions (supports annotation whitelist/blacklist) - - `rename-variable-transformation` - Rename fields/parameters/locals via LLM suggestions + - `rename-variable-transformation` - Rename fields/parameters/locals via LLM suggestions (supports annotation blacklist only) - `move-file-into-suggested-directory-transformation/ai` - Move files (LLM suggests destination) - `move-file-into-suggested-directory-transformation/config` - Move files (config specifies destination) - `add-comment-transformation` - Example transformation (adds comment to file start) @@ -35,10 +35,15 @@ CodeCocoon is an IntelliJ Platform plugin for **metamorphic testing** of Java pr - Controlled via `useMemory` and `generateWhenNotInMemory` config options - Auto-saves on transformation completion via `PersistentMemory.use {}` -6. **Annotation Filtering** (methods/classes only): - - **Whitelist mode**: Only rename elements WITH specified annotations - - **Blacklist mode** (recommended): Rename all EXCEPT those with specified annotations - - **`"_default"` keyword**: Merges 40+ framework annotations (Spring, JPA, JAX-RS, JUnit, etc.) with custom ones +6. **Annotation Filtering**: + - **Methods/Classes**: Support both whitelist and blacklist modes + - **Whitelist mode**: Only rename elements WITH specified annotations + - **Blacklist mode** (recommended): Rename all EXCEPT those with specified annotations + - **Variables**: Support blacklist mode only (no whitelist) + - **`"_default"` keyword**: Merges 35-40+ framework annotations with custom ones + - Methods: 40+ annotations (Spring, JPA, JAX-RS, JUnit, etc.) + - Classes: 25+ annotations (JPA, Spring, JAX-RS, JAXB, etc.) + - Variables: 35+ annotations (JPA, Jackson, JAXB, Spring, validation, CDI, etc.) - Warning logged if blacklist used without `"_default"` 7. **Configuration**: `codecocoon.yml` in project root defines transformations and target files diff --git a/README.md b/README.md index bbd617c..5bc6b64 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Renames Java variables (fields, parameters, locals) to LLM-suggested, semantical **Filters (variables are skipped if):** - In test sources - Enum constants -- Annotated with `@Column` +- Fail annotation filter (blacklist mode only - no whitelist support) - Declared in library/compiled code - Public/protected fields (to avoid breaking external consumers) @@ -143,12 +143,21 @@ Renames Java variables (fields, parameters, locals) to LLM-suggested, semantical ```yaml - id: "rename-variable-transformation" config: + # Memory configuration useMemory: true # Optional, default: false generateWhenNotInMemory: true # Optional, default: false searchInComments: false # Optional, default: false + + # Annotation blacklist filtering (no whitelist support) + blacklistedAnnotations: + - "_default" # Special keyword: includes 35+ framework annotations (JPA, Jackson, JAXB, Spring, validation, etc.) + - "MyCustomAnnotation" # Add your own annotations ``` -**Note:** Variable renaming does NOT support annotation filtering. +**Annotation filtering (blacklist mode only):** +- **Blacklist mode**: Rename all variables EXCEPT those with specified annotations. Use `"_default"` to include JPA (`@Column`/`@Id`/`@JoinColumn`), Jackson (`@JsonProperty`), JAXB (`@XmlElement`/`@XmlAttribute`), Spring (`@Value`/`@Autowired`), validation (`@NotNull`/`@Size`/`@Email`), and CDI (`@Inject`) annotations. +- **⚠ Warning**: Omitting `"_default"` in blacklist will NOT exclude framework annotations automatically. +- **Note**: Variables do NOT support whitelist mode (methods/classes only). --- diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt index a532e26..56118c9 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt @@ -48,10 +48,29 @@ class RenameVariableTransformation( val generateWhenNotInMemory = config.requireOrDefault("generateWhenNotInMemory", defaultValue = false) val searchInComments = config.requireOrDefault("searchInComments", defaultValue = false) + // Annotation filtering configuration (blacklist only - no whitelist support) + val blacklistedAnnotationsRaw = config.requireOrDefault>("blacklistedAnnotations", defaultValue = emptyList()) + + // Process blacklist: merge defaults if "_default" or "default" is present + val blacklistedAnnotations = if (blacklistedAnnotationsRaw.any { it.equals("_default", ignoreCase = true) || it.equals("default", ignoreCase = true) }) { + logger.info(" ↳ Include default blacklisted annotations ALONG with the custom ones (i.e., '_default' or 'default' keyword in the list)") + + val customAnnotations = blacklistedAnnotationsRaw.filter { !it.equals("_default", ignoreCase = true) && !it.equals("default", ignoreCase = true) } + (DEFAULT_BLACKLISTED_VARIABLE_ANNOTATIONS + customAnnotations).toList() + } else { + // Warn if using blacklist mode without defaults + if (blacklistedAnnotationsRaw.isNotEmpty()) { + logger.warn(" ⚠ Blacklist provided without '_default' keyword - framework annotations will NOT be automatically excluded") + } + blacklistedAnnotationsRaw + } + val document = withReadAction { psiFile.document() } val modifiedFiles = mutableSetOf() val value = if (document != null) { - val eligibleVariables: List = withReadAction { findAllValidVariables(psiFile) } + val eligibleVariables: List = withReadAction { + findAllValidVariables(psiFile, blacklistedAnnotations) + } if (eligibleVariables.isEmpty()) { return TransformationResult.Skipped("No matching variables found in ${virtualFile.name}") @@ -351,6 +370,30 @@ class RenameVariableTransformation( } } + /** + * Checks if annotations pass the blacklist filter. + * Variables renaming supports ONLY blacklist mode (no whitelist). + * + * @param annotations List of annotations to check + * @param blacklistedAnnotations Annotations to forbid (blacklist mode) + * @return true if annotations pass the filter (none are blacklisted), false otherwise + */ + private fun passesAnnotationFilter( + annotations: List, + blacklistedAnnotations: List, + ): Boolean { + if (annotations.isEmpty()) { + return true + } + + // Blacklist mode: No annotations can be in the blacklist + return annotations.none { annotation -> + val qualifiedName = annotation.qualifiedName + val simpleName = qualifiedName?.substringAfterLast('.') + qualifiedName in blacklistedAnnotations || simpleName in blacklistedAnnotations + } + } + /** * Checks if a single variable passes all filtering criteria. * Returns true if the variable should be included, false otherwise. @@ -358,6 +401,7 @@ class RenameVariableTransformation( private fun passesVariableFilters( variable: PsiVariable, psiFile: PsiFile, + blacklistedVariableAnnotations: List, ): Boolean { val fileIndex = ProjectFileIndex.getInstance(psiFile.project) @@ -373,10 +417,22 @@ class RenameVariableTransformation( return false } - // 3. Exclude @Column annotated variables - if (variable.annotations.any { it.qualifiedName?.contains("Column") == true }) { - logger.info(" ⊘ Variable `${variable.name}` - skipped (has @Column annotation)") - return false + // 3. Annotation filter (blacklist mode only) + val variableAnnotations = variable.annotations.toList() + val annotationsPass = passesAnnotationFilter( + variableAnnotations, + blacklistedVariableAnnotations + ) + + // Log annotation filtering for variables with annotations + if (variableAnnotations.isNotEmpty()) { + val annotationNames = variableAnnotations.mapNotNull { it.qualifiedName?.substringAfterLast('.') } + if (annotationsPass) { + logger.info(" ✓ Variable `${variable.name}` with annotations [${annotationNames.joinToString(", ")}] - allowed (not blacklisted)") + } else { + logger.info(" ⊘ Variable `${variable.name}` with annotations [${annotationNames.joinToString(", ")}] - skipped (blacklisted)") + return false + } } // 4. Exclude Library/Compiled Code @@ -398,13 +454,25 @@ class RenameVariableTransformation( /** * Identifies and filters valid variables from the provided PSI file based on specific criteria. - * The filtering logic excludes variables in test sources, enum constants, variables annotated with `@Column`, + * The filtering logic excludes variables in test sources, enum constants, blacklisted annotations, * variables from library or compiled code, and public/protected fields that could cause external breaking changes. * * @param psiFile The PSI file to traverse and analyze for variables. + * @param blacklistedVariableAnnotations Annotations to exclude (blacklist mode). * @return A list of PSI variables matching all filtering criteria. */ - private fun findAllValidVariables(psiFile: PsiFile): List { + private fun findAllValidVariables( + psiFile: PsiFile, + blacklistedVariableAnnotations: List, + ): List { + // Log annotation filter configuration + if (blacklistedVariableAnnotations.isNotEmpty()) { + logger.info(" ↳ Annotation filter mode: BLACKLIST") + logger.info(" ↳ Blacklisted variable annotations: [\n${blacklistedVariableAnnotations.joinToString(",\n") { "\t$it" } }\n]") + } else { + logger.info(" ↳ Annotation filter mode: BLACKLIST (empty - all annotations allowed)") + } + val variables = mutableListOf() psiFile.accept(object : PsiRecursiveElementVisitor() { @@ -417,7 +485,7 @@ class RenameVariableTransformation( }) val filteredVariables = variables.filter { v -> - passesVariableFilters(v, psiFile) + passesVariableFilters(v, psiFile, blacklistedVariableAnnotations) } if (filteredVariables.isNotEmpty()) { @@ -431,5 +499,75 @@ class RenameVariableTransformation( companion object { const val ID = "rename-variable-transformation" private const val DEFAULT_SUGGESTED_NAMES_SIZE = 3 + + /** + * Default blacklisted variable annotations (framework/infrastructure annotations). + * These annotations typically indicate variables that are mapped to external systems, + * so renaming them could break runtime behavior or data binding. + */ + val DEFAULT_BLACKLISTED_VARIABLE_ANNOTATIONS = setOf( + // JPA/Hibernate + "javax.persistence.Column", + "javax.persistence.Id", + "javax.persistence.GeneratedValue", + "javax.persistence.Version", + "javax.persistence.Temporal", + "javax.persistence.Enumerated", + "javax.persistence.Lob", + "javax.persistence.Basic", + "javax.persistence.EmbeddedId", + "javax.persistence.JoinColumn", + "jakarta.persistence.Column", + "jakarta.persistence.Id", + "jakarta.persistence.GeneratedValue", + "jakarta.persistence.Version", + "jakarta.persistence.Temporal", + "jakarta.persistence.Enumerated", + "jakarta.persistence.Lob", + "jakarta.persistence.Basic", + "jakarta.persistence.EmbeddedId", + "jakarta.persistence.JoinColumn", + + // Jackson (JSON) + "com.fasterxml.jackson.annotation.JsonProperty", + "com.fasterxml.jackson.annotation.JsonIgnore", + "com.fasterxml.jackson.annotation.JsonAlias", + + // JAXB (XML) + "javax.xml.bind.annotation.XmlElement", + "javax.xml.bind.annotation.XmlAttribute", + "javax.xml.bind.annotation.XmlTransient", + "javax.xml.bind.annotation.XmlID", + "jakarta.xml.bind.annotation.XmlElement", + "jakarta.xml.bind.annotation.XmlAttribute", + "jakarta.xml.bind.annotation.XmlTransient", + "jakarta.xml.bind.annotation.XmlID", + + // Spring Framework + "org.springframework.beans.factory.annotation.Value", + "org.springframework.beans.factory.annotation.Autowired", + "org.springframework.beans.factory.annotation.Qualifier", + "javax.annotation.Resource", + + // Bean Validation + "javax.validation.constraints.NotNull", + "javax.validation.constraints.Size", + "javax.validation.constraints.Min", + "javax.validation.constraints.Max", + "javax.validation.constraints.Pattern", + "javax.validation.constraints.Email", + "jakarta.validation.constraints.NotNull", + "jakarta.validation.constraints.Size", + "jakarta.validation.constraints.Min", + "jakarta.validation.constraints.Max", + "jakarta.validation.constraints.Pattern", + "jakarta.validation.constraints.Email", + + // CDI + "javax.inject.Inject", + "javax.inject.Named", + "jakarta.inject.Inject", + "jakarta.inject.Named" + ) } } \ No newline at end of file From 9424cda793058dcc93f174e097e5470c9ad382f5 Mon Sep 17 00:00:00 2001 From: dragoi75 Date: Mon, 13 Apr 2026 09:29:59 +0200 Subject: [PATCH 19/67] feat: Add support for transforming `problem_statement` using transformation memory. --- build.gradle.kts | 25 +++ .../services/MetamorphicTextTransformer.kt | 192 ++++++++++++++++++ .../appstarter/TransformTextsStarter.kt | 75 +++++++ src/main/resources/META-INF/plugin.xml | 2 + transform_metamorphic_texts.py | 192 ++++++++++++++++++ 5 files changed, 486 insertions(+) create mode 100644 core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt create mode 100644 src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/TransformTextsStarter.kt create mode 100755 transform_metamorphic_texts.py diff --git a/build.gradle.kts b/build.gradle.kts index 8c3ad7b..91ab0c9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -235,5 +235,30 @@ intellijPlatformTesting { ) } } + + // Custom task for metamorphic text transformation (runs within IntelliJ platform) + register("transformMetamorphicTexts") { + task { + // Program arguments for the transformation + args(listOf("transform-texts")) + + // Pass parameters via system properties + val memoryFile = project.findProperty("memoryFile") as? String ?: "" + val problemStatement = project.findProperty("problemStatement") as? String ?: "" + val interfaceDesc = project.findProperty("interfaceDesc") as? String ?: "" + val outputFile = project.findProperty("outputFile") as? String ?: "" + + // JVM arguments + jvmArgs( + "-Xmx4G", + "-Djava.awt.headless=true", + "--add-exports", "java.base/jdk.internal.vm=ALL-UNNAMED", + "-Dtransform.memoryFile=${memoryFile}", + "-Dtransform.problemStatement=${problemStatement}", + "-Dtransform.interfaceDesc=${interfaceDesc}", + "-Dtransform.outputFile=${outputFile}", + ) + } + } } } diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt new file mode 100644 index 0000000..bd566ba --- /dev/null +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt @@ -0,0 +1,192 @@ +package com.github.pderakhshanfar.codecocoonplugin.services + +import ai.koog.prompt.dsl.Prompt +import ai.koog.prompt.executor.clients.openai.OpenAIModels +import com.github.pderakhshanfar.codecocoonplugin.common.LLM +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.encodeToString +import java.io.File + +/** + * Transforms problem statements and interface descriptions for metamorphic test instances + * by updating class and method names according to the rename memory. + */ +class MetamorphicTextTransformer( + private val llm: LLM, +) { + + @Serializable + data class TransformedTexts( + val problemStatement: String, + val interfaceDescription: String, + ) + + /** + * Transforms both the problem statement and interface description using the memory file. + * + * @param problemStatement The original problem statement + * @param interfaceDescription The original interface description + * @param memoryFilePath Path to the memory JSON file containing class/method renames + * @return TransformedTexts with updated names + */ + suspend fun transformTexts( + problemStatement: String, + interfaceDescription: String, + memoryFilePath: String, + ): TransformedTexts? { + // Load memory file + val memoryFile = File(memoryFilePath) + if (!memoryFile.exists()) { + println("ERROR: Memory file not found: $memoryFilePath") + return null + } + + val memoryJson = Json.parseToJsonElement(memoryFile.readText()) + val entries = memoryJson.jsonObject["entries"]?.jsonObject + + if (entries == null) { + println("ERROR: No 'entries' field found in memory file") + return null + } + + // Build a mapping of old names to new names + val renameMap = mutableMapOf() + entries.entries.forEach { (oldName, newName) -> + renameMap[oldName] = newName.jsonPrimitive.content + } + + if (renameMap.isEmpty()) { + println("WARNING: No rename entries found in memory file") + return TransformedTexts(problemStatement, interfaceDescription) + } + + // Create the prompt + val prompt = createTransformationPrompt( + problemStatement = problemStatement, + interfaceDescription = interfaceDescription, + renameMap = renameMap + ) + + // Call LLM + val result = llm.structuredRequest( + prompt = prompt, + maxRetries = 3, + maxFixingAttempts = 2 + ) + + return result + } + + private fun createTransformationPrompt( + problemStatement: String, + interfaceDescription: String, + renameMap: Map + ): Prompt { + return Prompt.build("metamorphic-text-transformer") { + system { + text(""" + You are a technical documentation assistant helping to update code descriptions + after refactoring transformations have been applied. + + You will be given: + 1. An original problem statement + 2. An original interface description + 3. A mapping of old class/method names to new names + + Your task: + - Update the problem statement and interface description to use the NEW names + - Keep the meaning and structure exactly the same + - Only change the class, method, and variable names according to the mapping + - Preserve all formatting, punctuation, and sentence structure + - If a name doesn't appear in the mapping, leave it unchanged + + Important: + - Do NOT add new information + - Do NOT remove information + - Do NOT rephrase or rewrite the content + - ONLY update the names that appear in the rename mapping + """.trimIndent()) + } + + user { + text("## Original Problem Statement\n") + text(problemStatement) + text("\n\n") + + text("## Original Interface Description\n") + text(interfaceDescription) + text("\n\n") + + text("## Rename Mapping (OldName -> NewName)\n") + renameMap.forEach { (old, new) -> + // Extract simple class name from fully qualified name + val oldSimple = old.substringAfterLast('.') + val newSimple = new + text("- $oldSimple -> $newSimple\n") + } + + text("\n") + text("Now, update the problem statement and interface description with the new names.") + } + } + } +} + +/** + * CLI entry point for transforming texts from command line. + * + * Usage: + * ./gradlew transformMetamorphicTexts \ + * -PproblemStatement="..." \ + * -PinterfaceDesc="..." \ + * -PmemoryFile="/path/to/memory.json" \ + * -PoutputFile="/path/to/output.json" + */ +suspend fun main(args: Array) { + if (args.size != 4) { + println("Usage: transformMetamorphicTexts ") + return + } + + val problemStatement = args[0] + val interfaceDesc = args[1] + val memoryFile = args[2] + val outputFile = args[3] + + try { + // Initialize LLM (using GPT-4 via Grazie) + val token = System.getenv("GRAZIE_TOKEN") ?: run { + println("ERROR: GRAZIE_TOKEN environment variable not set") + return + } + + val llm = LLM.fromGrazie( + model = OpenAIModels.Chat.GPT5Mini, + token = token + ) + + val transformer = MetamorphicTextTransformer(llm) + + println("Transforming texts using memory file: $memoryFile") + val result = transformer.transformTexts( + problemStatement = problemStatement, + interfaceDescription = interfaceDesc, + memoryFilePath = memoryFile + ) + + if (result != null) { + // Write result to JSON file + val json = Json.encodeToString(result) + File(outputFile).writeText(json) + println("SUCCESS: Transformed texts written to: $outputFile") + } else { + println("ERROR: Failed to transform texts") + } + } catch (e: Exception) { + println("ERROR: ${e.message}") + e.printStackTrace() + } +} diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/TransformTextsStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/TransformTextsStarter.kt new file mode 100644 index 0000000..82f2af0 --- /dev/null +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/TransformTextsStarter.kt @@ -0,0 +1,75 @@ +package com.github.pderakhshanfar.codecocoonplugin.appstarter + +import ai.koog.prompt.executor.clients.openai.OpenAIModels +import com.github.pderakhshanfar.codecocoonplugin.common.LLM +import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout +import com.github.pderakhshanfar.codecocoonplugin.services.MetamorphicTextTransformer +import com.intellij.openapi.application.ApplicationStarter +import com.intellij.openapi.diagnostic.thisLogger +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import kotlin.system.exitProcess + +/** + * Application starter for running text transformation in headless mode. + * This is the entry point when the IDE is launched with the 'transform-texts' command. + */ +class TransformTextsStarter : ApplicationStarter { + override val requiredModality: Int = ApplicationStarter.NOT_IN_EDT + private val logger = thisLogger().withStdout() + + override fun main(args: List) { + val memoryFile = System.getProperty("transform.memoryFile") ?: "" + val problemStatement = System.getProperty("transform.problemStatement") ?: "" + val interfaceDesc = System.getProperty("transform.interfaceDesc") ?: "" + val outputFile = System.getProperty("transform.outputFile") ?: "" + + if (memoryFile.isEmpty() || problemStatement.isEmpty() || interfaceDesc.isEmpty() || outputFile.isEmpty()) { + logger.error("[TransformTexts] Missing required parameters") + logger.error("[TransformTexts] Required system properties: transform.memoryFile, transform.problemStatement, transform.interfaceDesc, transform.outputFile") + exitProcess(1) + } + + val token = System.getenv("GRAZIE_TOKEN") + if (token == null) { + logger.error("[TransformTexts] GRAZIE_TOKEN environment variable not set") + exitProcess(1) + } + + runBlocking { + try { + logger.info("[TransformTexts] Starting text transformation") + logger.info("[TransformTexts] Memory file: $memoryFile") + + val llm = LLM.fromGrazie( + model = OpenAIModels.Chat.GPT5Mini, + token = token + ) + + val transformer = MetamorphicTextTransformer(llm) + + val result = transformer.transformTexts( + problemStatement = problemStatement, + interfaceDescription = interfaceDesc, + memoryFilePath = memoryFile + ) + + if (result != null) { + val json = Json.encodeToString(result) + File(outputFile).writeText(json) + logger.info("[TransformTexts] SUCCESS: Transformed texts written to: $outputFile") + exitProcess(0) + } else { + logger.error("[TransformTexts] ERROR: Failed to transform texts") + exitProcess(1) + } + } catch (e: Exception) { + logger.error("[TransformTexts] ERROR: ${e.message}", e) + e.printStackTrace(System.err) + exitProcess(1) + } + } + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index fb22a79..6da7aff 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -13,5 +13,7 @@ + diff --git a/transform_metamorphic_texts.py b/transform_metamorphic_texts.py new file mode 100755 index 0000000..5a55949 --- /dev/null +++ b/transform_metamorphic_texts.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 + +""" +Metamorphic Text Transformer +============================================================================ +Transforms problem statements and interface descriptions using LLM +to reflect renamed classes/methods from the memory file. + +Usage: ./transform_metamorphic_texts.py +============================================================================ +""" + +import json +import os +import sys +import requests + + +def load_memory_file(memory_file_path): + """Load and parse the memory file.""" + with open(memory_file_path, 'r') as f: + memory_data = json.load(f) + return memory_data.get('entries', {}) + + +def create_prompt(problem_statement, interface_desc, rename_map): + """Create the LLM prompt.""" + # Build rename mappings string + rename_list = [] + for old_name, new_name in rename_map.items(): + old_simple = old_name.split('.')[-1] + rename_list.append(f"- {old_simple} -> {new_name}") + + rename_mappings = "\n".join(rename_list) + + system_prompt = """You are a technical documentation assistant helping to update code descriptions after refactoring transformations have been applied. + +You will be given: +1. An original problem statement +2. An original interface description +3. A mapping of old class/method names to new names + +Your task: +- Update the problem statement and interface description to use the NEW names +- Keep the meaning and structure exactly the same +- Only change the class, method, and variable names according to the mapping +- Preserve all formatting, punctuation, and sentence structure +- If a name doesn't appear in the mapping, leave it unchanged + +Important: +- Do NOT add new information +- Do NOT remove information +- Do NOT rephrase or rewrite the content +- ONLY update the names that appear in the rename mapping + +Respond ONLY with a JSON object in this exact format: +{ + "problemStatement": "updated problem statement here", + "interfaceDescription": "updated interface description here" +}""" + + user_prompt = f"""## Original Problem Statement +{problem_statement} + +## Original Interface Description +{interface_desc} + +## Rename Mapping (OldName -> NewName) +{rename_mappings} + +Now, update the problem statement and interface description with the new names. +Respond with ONLY the JSON object, no additional text.""" + + return system_prompt, user_prompt + + +def call_grazie_api(system_prompt, user_prompt, token): + """Call the Grazie API.""" + url = "https://api.grazie.aws.intellij.net/chat/v1/completions" + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + payload = { + "model": "gpt-4o", + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + "temperature": 0.3 + } + + response = requests.post(url, headers=headers, json=payload) + + if response.status_code != 200: + print(f"ERROR: API request failed with status {response.status_code}") + print(response.text) + sys.exit(1) + + response_json = response.json() + content = response_json['choices'][0]['message']['content'] + + return content + + +def extract_json(content): + """Extract JSON from potential markdown code blocks.""" + # Remove markdown code blocks if present + if '```json' in content: + content = content.split('```json')[1].split('```')[0].strip() + elif '```' in content: + content = content.split('```')[1].split('```')[0].strip() + + return content.strip() + + +def main(): + if len(sys.argv) != 5: + print("Usage: ./transform_metamorphic_texts.py ") + sys.exit(1) + + memory_file = sys.argv[1] + problem_statement = sys.argv[2] + interface_desc = sys.argv[3] + output_file = sys.argv[4] + + # Check if memory file exists + if not os.path.exists(memory_file): + print(f"ERROR: Memory file not found: {memory_file}") + sys.exit(1) + + # Check if GRAZIE_TOKEN is set + token = os.getenv('GRAZIE_TOKEN') + if not token: + print("ERROR: GRAZIE_TOKEN environment variable not set") + sys.exit(1) + + try: + # Load memory file + print(f"Loading memory file: {memory_file}") + rename_map = load_memory_file(memory_file) + + if not rename_map: + print("WARNING: No rename entries found in memory file") + # Return original texts unchanged + result = { + "problemStatement": problem_statement, + "interfaceDescription": interface_desc + } + with open(output_file, 'w') as f: + json.dump(result, f, indent=2) + print(f"SUCCESS: Original texts written to: {output_file}") + return + + print(f"Found {len(rename_map)} rename entries") + + # Create prompt + system_prompt, user_prompt = create_prompt(problem_statement, interface_desc, rename_map) + + # Call API + print("Calling Grazie API...") + content = call_grazie_api(system_prompt, user_prompt, token) + + # Extract JSON + json_content = extract_json(content) + + # Parse and validate + result = json.loads(json_content) + + # Ensure required fields exist + if 'problemStatement' not in result or 'interfaceDescription' not in result: + print("ERROR: API response missing required fields") + print(f"Response: {json_content}") + sys.exit(1) + + # Write to output file + with open(output_file, 'w') as f: + json.dump(result, f, indent=2) + + print(f"SUCCESS: Transformed texts written to: {output_file}") + + except Exception as e: + print(f"ERROR: {str(e)}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() From c04019032265a7ff5b6cf906ec65656e6f1b087c Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Thu, 23 Apr 2026 21:22:16 +0200 Subject: [PATCH 20/67] feat: delete transform_metamorphic_texts.py --- transform_metamorphic_texts.py | 192 --------------------------------- 1 file changed, 192 deletions(-) delete mode 100755 transform_metamorphic_texts.py diff --git a/transform_metamorphic_texts.py b/transform_metamorphic_texts.py deleted file mode 100755 index 5a55949..0000000 --- a/transform_metamorphic_texts.py +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env python3 - -""" -Metamorphic Text Transformer -============================================================================ -Transforms problem statements and interface descriptions using LLM -to reflect renamed classes/methods from the memory file. - -Usage: ./transform_metamorphic_texts.py -============================================================================ -""" - -import json -import os -import sys -import requests - - -def load_memory_file(memory_file_path): - """Load and parse the memory file.""" - with open(memory_file_path, 'r') as f: - memory_data = json.load(f) - return memory_data.get('entries', {}) - - -def create_prompt(problem_statement, interface_desc, rename_map): - """Create the LLM prompt.""" - # Build rename mappings string - rename_list = [] - for old_name, new_name in rename_map.items(): - old_simple = old_name.split('.')[-1] - rename_list.append(f"- {old_simple} -> {new_name}") - - rename_mappings = "\n".join(rename_list) - - system_prompt = """You are a technical documentation assistant helping to update code descriptions after refactoring transformations have been applied. - -You will be given: -1. An original problem statement -2. An original interface description -3. A mapping of old class/method names to new names - -Your task: -- Update the problem statement and interface description to use the NEW names -- Keep the meaning and structure exactly the same -- Only change the class, method, and variable names according to the mapping -- Preserve all formatting, punctuation, and sentence structure -- If a name doesn't appear in the mapping, leave it unchanged - -Important: -- Do NOT add new information -- Do NOT remove information -- Do NOT rephrase or rewrite the content -- ONLY update the names that appear in the rename mapping - -Respond ONLY with a JSON object in this exact format: -{ - "problemStatement": "updated problem statement here", - "interfaceDescription": "updated interface description here" -}""" - - user_prompt = f"""## Original Problem Statement -{problem_statement} - -## Original Interface Description -{interface_desc} - -## Rename Mapping (OldName -> NewName) -{rename_mappings} - -Now, update the problem statement and interface description with the new names. -Respond with ONLY the JSON object, no additional text.""" - - return system_prompt, user_prompt - - -def call_grazie_api(system_prompt, user_prompt, token): - """Call the Grazie API.""" - url = "https://api.grazie.aws.intellij.net/chat/v1/completions" - - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - } - - payload = { - "model": "gpt-4o", - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt} - ], - "temperature": 0.3 - } - - response = requests.post(url, headers=headers, json=payload) - - if response.status_code != 200: - print(f"ERROR: API request failed with status {response.status_code}") - print(response.text) - sys.exit(1) - - response_json = response.json() - content = response_json['choices'][0]['message']['content'] - - return content - - -def extract_json(content): - """Extract JSON from potential markdown code blocks.""" - # Remove markdown code blocks if present - if '```json' in content: - content = content.split('```json')[1].split('```')[0].strip() - elif '```' in content: - content = content.split('```')[1].split('```')[0].strip() - - return content.strip() - - -def main(): - if len(sys.argv) != 5: - print("Usage: ./transform_metamorphic_texts.py ") - sys.exit(1) - - memory_file = sys.argv[1] - problem_statement = sys.argv[2] - interface_desc = sys.argv[3] - output_file = sys.argv[4] - - # Check if memory file exists - if not os.path.exists(memory_file): - print(f"ERROR: Memory file not found: {memory_file}") - sys.exit(1) - - # Check if GRAZIE_TOKEN is set - token = os.getenv('GRAZIE_TOKEN') - if not token: - print("ERROR: GRAZIE_TOKEN environment variable not set") - sys.exit(1) - - try: - # Load memory file - print(f"Loading memory file: {memory_file}") - rename_map = load_memory_file(memory_file) - - if not rename_map: - print("WARNING: No rename entries found in memory file") - # Return original texts unchanged - result = { - "problemStatement": problem_statement, - "interfaceDescription": interface_desc - } - with open(output_file, 'w') as f: - json.dump(result, f, indent=2) - print(f"SUCCESS: Original texts written to: {output_file}") - return - - print(f"Found {len(rename_map)} rename entries") - - # Create prompt - system_prompt, user_prompt = create_prompt(problem_statement, interface_desc, rename_map) - - # Call API - print("Calling Grazie API...") - content = call_grazie_api(system_prompt, user_prompt, token) - - # Extract JSON - json_content = extract_json(content) - - # Parse and validate - result = json.loads(json_content) - - # Ensure required fields exist - if 'problemStatement' not in result or 'interfaceDescription' not in result: - print("ERROR: API response missing required fields") - print(f"Response: {json_content}") - sys.exit(1) - - # Write to output file - with open(output_file, 'w') as f: - json.dump(result, f, indent=2) - - print(f"SUCCESS: Transformed texts written to: {output_file}") - - except Exception as e: - print(f"ERROR: {str(e)}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == '__main__': - main() From c61e25cf8f73f87fed694b2a87e3c4ad14278afb Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 25 Apr 2026 15:59:12 +0200 Subject: [PATCH 21/67] feat: move `MoveFileIntoSuggestedDirectoryTransformation` to `structural` package --- .../codecocoonplugin/appstarter/HeadlessModeStarter.kt | 2 +- .../MoveFileIntoSuggestedDirectoryTransformation.kt | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) rename src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/{ => structural}/MoveFileIntoSuggestedDirectoryTransformation.kt (98%) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt index 23e5275..6f5cdd9 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt @@ -1,7 +1,7 @@ package com.github.pderakhshanfar.codecocoonplugin.appstarter import com.github.pderakhshanfar.codecocoonplugin.components.transformations.AddCommentTransformation -import com.github.pderakhshanfar.codecocoonplugin.components.transformations.MoveFileIntoSuggestedDirectoryTransformation +import com.github.pderakhshanfar.codecocoonplugin.components.transformations.structural.MoveFileIntoSuggestedDirectoryTransformation import com.github.pderakhshanfar.codecocoonplugin.components.transformations.TransformationRegistry import com.github.pderakhshanfar.codecocoonplugin.components.transformations.renaming.RenameClassTransformation import com.github.pderakhshanfar.codecocoonplugin.components.transformations.renaming.RenameMethodTransformation diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/MoveFileIntoSuggestedDirectoryTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt similarity index 98% rename from src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/MoveFileIntoSuggestedDirectoryTransformation.kt rename to src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt index ffcbac0..5cdfd36 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/MoveFileIntoSuggestedDirectoryTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt @@ -1,9 +1,10 @@ -package com.github.pderakhshanfar.codecocoonplugin.components.transformations +package com.github.pderakhshanfar.codecocoonplugin.components.transformations.structural import com.github.pderakhshanfar.codecocoonplugin.common.TransformationStepFailed import com.github.pderakhshanfar.codecocoonplugin.components.transformations.IntelliJAwareTransformation.Companion.withReadAction -import com.github.pderakhshanfar.codecocoonplugin.components.transformations.MoveFileIntoSuggestedDirectoryTransformation.Companion.withAI -import com.github.pderakhshanfar.codecocoonplugin.components.transformations.MoveFileIntoSuggestedDirectoryTransformation.Companion.withConfig +import com.github.pderakhshanfar.codecocoonplugin.components.transformations.SelfManagedTransformation +import com.github.pderakhshanfar.codecocoonplugin.components.transformations.structural.MoveFileIntoSuggestedDirectoryTransformation.Companion.withAI +import com.github.pderakhshanfar.codecocoonplugin.components.transformations.structural.MoveFileIntoSuggestedDirectoryTransformation.Companion.withConfig import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.declarations @@ -28,6 +29,7 @@ import com.intellij.usageView.UsageInfo import kotlinx.coroutines.runBlocking import java.nio.file.Paths import java.util.concurrent.CompletableFuture +import kotlin.collections.iterator /** From 6a19de20a69313771e648abf0a616a453730e42f Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 25 Apr 2026 16:26:51 +0200 Subject: [PATCH 22/67] feat: implement `ReorderClassMethodsTransformation` that reorders methods in reverse-alphabetical order --- .../appstarter/HeadlessModeStarter.kt | 8 + .../ReorderClassMethodsTransformation.kt | 142 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt index 6f5cdd9..88230bb 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt @@ -6,6 +6,7 @@ import com.github.pderakhshanfar.codecocoonplugin.components.transformations.Tra import com.github.pderakhshanfar.codecocoonplugin.components.transformations.renaming.RenameClassTransformation import com.github.pderakhshanfar.codecocoonplugin.components.transformations.renaming.RenameMethodTransformation import com.github.pderakhshanfar.codecocoonplugin.components.transformations.renaming.RenameVariableTransformation +import com.github.pderakhshanfar.codecocoonplugin.components.transformations.structural.ReorderClassMethodsTransformation import com.github.pderakhshanfar.codecocoonplugin.config.CodeCocoonConfig import com.github.pderakhshanfar.codecocoonplugin.config.ConfigLoader import com.github.pderakhshanfar.codecocoonplugin.intellij.JvmProjectConfigurator @@ -193,10 +194,13 @@ class HeadlessModeStarter : ApplicationStarter { * unique ID in the registry, allowing it to be referenced dynamically during execution. */ private fun registerBuiltInTransformations() { + // renaming TransformationRegistry.register(AddCommentTransformation.ID) { config -> AddCommentTransformation(config) } TransformationRegistry.register(RenameMethodTransformation.ID) { config -> RenameMethodTransformation(config) } TransformationRegistry.register(RenameClassTransformation.ID) { config -> RenameClassTransformation(config) } TransformationRegistry.register(RenameVariableTransformation.ID) { config -> RenameVariableTransformation(config) } + + // structural // move file transformation: // 1) with AI suggested directory TransformationRegistry.register(MoveFileIntoSuggestedDirectoryTransformation.Companion.AI.ID) { config -> @@ -206,6 +210,10 @@ class HeadlessModeStarter : ApplicationStarter { TransformationRegistry.register(MoveFileIntoSuggestedDirectoryTransformation.Companion.Config.ID) { config -> MoveFileIntoSuggestedDirectoryTransformation.withConfig(config) } + // reorder class methods transformation + TransformationRegistry.register(ReorderClassMethodsTransformation.ID) { config -> + ReorderClassMethodsTransformation(config) + } } /** diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt new file mode 100644 index 0000000..75795e7 --- /dev/null +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt @@ -0,0 +1,142 @@ +package com.github.pderakhshanfar.codecocoonplugin.components.transformations.structural + +import com.github.pderakhshanfar.codecocoonplugin.components.transformations.IntelliJAwareTransformation +import com.github.pderakhshanfar.codecocoonplugin.components.transformations.SelfManagedTransformation +import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult +import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout +import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.document +import com.github.pderakhshanfar.codecocoonplugin.java.JavaTransformation +import com.github.pderakhshanfar.codecocoonplugin.memory.Memory +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiRecursiveElementVisitor + +/** + * Reorders methods in a class in a *reverse alphabetic order* (Z -> A). + */ +class ReorderClassMethodsTransformation( + override val config: Map +) : JavaTransformation, SelfManagedTransformation() { + override val id: String = ID + override val description: String = "Reorders methods in a class in reverse alphabetic order (Z -> A)" + private val logger = thisLogger().withStdout() + + override fun apply( + psiFile: PsiFile, + virtualFile: VirtualFile, + memory: Memory? + ): TransformationResult { + return try { + if (psiFile !is PsiJavaFile) { + return TransformationResult.Skipped("File ${virtualFile.name} is not a Java file") + } + + val project = psiFile.project + val classes = IntelliJAwareTransformation.withReadAction { collectAllClasses(psiFile) } + + if (classes.isEmpty()) { + return TransformationResult.Skipped("No classes found in ${virtualFile.name}") + } + + var reorderedClassCount = 0 + var totalMethodsTouched = 0 + + ApplicationManager.getApplication().invokeAndWait { + WriteCommandAction.runWriteCommandAction(project, "Reorder Class Methods", null, { + PsiDocumentManager.getInstance(project).commitAllDocuments() + + for (psiClass in classes) { + val methods = psiClass.methods.toList() + if (methods.size < 2) { + logger.warn(" ⊘ Class `${psiClass.name}` - has ${methods.size} methods (skipping)") + continue + } + + val sortedMethods = reorderMethods(methods) + + if (sortedMethods.map { it.name } == methods.map { it.name }) { + logger.info(" ⊘ Class `${psiClass.name}` - methods already in desired order") + continue + } + + val rBrace = psiClass.rBrace + if (rBrace == null) { + logger.warn(" ⊘ Class `${psiClass.name}` - no closing brace, skipping") + continue + } + + // add sorted methods into class + for (method in sortedMethods) { + psiClass.addBefore(method.copy(), rBrace) + } + // remove original methods + for (method in methods) { + method.delete() + } + + reorderedClassCount += 1 + totalMethodsTouched += methods.size + logger.info(" ✓ Class `${psiClass.name}` - reordered ${methods.size} methods") + } + + val document = psiFile.document() + if (document != null) { + PsiDocumentManager.getInstance(project).commitDocument(document) + FileDocumentManager.getInstance().saveDocument(document) + } else { + logger.warn(" ⚠ Could not get document for ${virtualFile.name}; changes may not be flushed to disk") + } + }) + } + + if (reorderedClassCount == 0) { + TransformationResult.Skipped("Nothing to reorder in ${virtualFile.name}") + } else { + TransformationResult.Success( + message = "Reordered $totalMethodsTouched methods across $reorderedClassCount class(es) in ${virtualFile.name}", + filesModified = 1, + ) + } + } + catch (err: ProcessCanceledException) { + throw err + } + catch (e: Exception) { + TransformationResult.Failure("Failed to reorder methods in ${virtualFile.name}", e) + } + } + + /** + * Returns methods in the desired order. Reverse-alphabetical (Z → A) for now. + * Future config params will be wired here to switch strategies. + */ + private fun reorderMethods(methods: List): List = + methods.sortedByDescending { it.name } + + private fun collectAllClasses(psiFile: PsiFile): List { + val classes = mutableListOf() + psiFile.accept(object : PsiRecursiveElementVisitor() { + override fun visitElement(element: PsiElement) { + super.visitElement(element) + if (element is PsiClass) { + classes.add(element) + } + } + }) + return classes + } + + companion object { + const val ID = "reorder-class-methods-transformation" + } +} From 7de90e148b25edef91d95db5cbe644dd5896da38 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 25 Apr 2026 16:50:07 +0200 Subject: [PATCH 23/67] refactor: remove `interfaceDesc` field from `MetamorphicTextTransformer` and related components --- build.gradle.kts | 2 - .../appstarter/TransformTextsStarter.kt | 6 +- .../services/MetamorphicTextTransformer.kt | 79 +++++++++++-------- 3 files changed, 48 insertions(+), 39 deletions(-) rename {core/src => src}/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt (62%) diff --git a/build.gradle.kts b/build.gradle.kts index 91ab0c9..0d077eb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -245,7 +245,6 @@ intellijPlatformTesting { // Pass parameters via system properties val memoryFile = project.findProperty("memoryFile") as? String ?: "" val problemStatement = project.findProperty("problemStatement") as? String ?: "" - val interfaceDesc = project.findProperty("interfaceDesc") as? String ?: "" val outputFile = project.findProperty("outputFile") as? String ?: "" // JVM arguments @@ -255,7 +254,6 @@ intellijPlatformTesting { "--add-exports", "java.base/jdk.internal.vm=ALL-UNNAMED", "-Dtransform.memoryFile=${memoryFile}", "-Dtransform.problemStatement=${problemStatement}", - "-Dtransform.interfaceDesc=${interfaceDesc}", "-Dtransform.outputFile=${outputFile}", ) } diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/TransformTextsStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/TransformTextsStarter.kt index 82f2af0..c78def2 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/TransformTextsStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/TransformTextsStarter.kt @@ -23,12 +23,11 @@ class TransformTextsStarter : ApplicationStarter { override fun main(args: List) { val memoryFile = System.getProperty("transform.memoryFile") ?: "" val problemStatement = System.getProperty("transform.problemStatement") ?: "" - val interfaceDesc = System.getProperty("transform.interfaceDesc") ?: "" val outputFile = System.getProperty("transform.outputFile") ?: "" - if (memoryFile.isEmpty() || problemStatement.isEmpty() || interfaceDesc.isEmpty() || outputFile.isEmpty()) { + if (memoryFile.isEmpty() || problemStatement.isEmpty() || outputFile.isEmpty()) { logger.error("[TransformTexts] Missing required parameters") - logger.error("[TransformTexts] Required system properties: transform.memoryFile, transform.problemStatement, transform.interfaceDesc, transform.outputFile") + logger.error("[TransformTexts] Required system properties: transform.memoryFile, transform.problemStatement, transform.outputFile") exitProcess(1) } @@ -52,7 +51,6 @@ class TransformTextsStarter : ApplicationStarter { val result = transformer.transformTexts( problemStatement = problemStatement, - interfaceDescription = interfaceDesc, memoryFilePath = memoryFile ) diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt similarity index 62% rename from core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt rename to src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt index bd566ba..fe20735 100644 --- a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt @@ -3,6 +3,8 @@ package com.github.pderakhshanfar.codecocoonplugin.services import ai.koog.prompt.dsl.Prompt import ai.koog.prompt.executor.clients.openai.OpenAIModels import com.github.pderakhshanfar.codecocoonplugin.common.LLM +import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout +import com.intellij.openapi.diagnostic.thisLogger import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject @@ -11,36 +13,36 @@ import kotlinx.serialization.encodeToString import java.io.File /** - * Transforms problem statements and interface descriptions for metamorphic test instances - * by updating class and method names according to the rename memory. + * Transforms problem statements for metamorphic test instances by updating class, method, + * and variable names — and package references when files were moved — according to the + * rename memory. */ class MetamorphicTextTransformer( private val llm: LLM, ) { + private val logger = thisLogger().withStdout() @Serializable data class TransformedTexts( val problemStatement: String, - val interfaceDescription: String, ) /** - * Transforms both the problem statement and interface description using the memory file. + * Transforms the problem statement using the memory file. * * @param problemStatement The original problem statement - * @param interfaceDescription The original interface description * @param memoryFilePath Path to the memory JSON file containing class/method renames + * and (optionally) file-move entries * @return TransformedTexts with updated names */ suspend fun transformTexts( problemStatement: String, - interfaceDescription: String, memoryFilePath: String, ): TransformedTexts? { // Load memory file val memoryFile = File(memoryFilePath) if (!memoryFile.exists()) { - println("ERROR: Memory file not found: $memoryFilePath") + logger.error("ERROR: Memory file not found: $memoryFilePath") return null } @@ -48,7 +50,7 @@ class MetamorphicTextTransformer( val entries = memoryJson.jsonObject["entries"]?.jsonObject if (entries == null) { - println("ERROR: No 'entries' field found in memory file") + logger.error("ERROR: No 'entries' field found in memory file") return null } @@ -59,16 +61,16 @@ class MetamorphicTextTransformer( } if (renameMap.isEmpty()) { - println("WARNING: No rename entries found in memory file") - return TransformedTexts(problemStatement, interfaceDescription) + logger.warn("WARNING: No rename entries found in memory file") + return TransformedTexts(problemStatement) } // Create the prompt val prompt = createTransformationPrompt( problemStatement = problemStatement, - interfaceDescription = interfaceDescription, renameMap = renameMap ) + logger.info("Created prompt:\n'''$prompt\n'''") // Call LLM val result = llm.structuredRequest( @@ -82,7 +84,6 @@ class MetamorphicTextTransformer( private fun createTransformationPrompt( problemStatement: String, - interfaceDescription: String, renameMap: Map ): Prompt { return Prompt.build("metamorphic-text-transformer") { @@ -93,13 +94,23 @@ class MetamorphicTextTransformer( You will be given: 1. An original problem statement - 2. An original interface description - 3. A mapping of old class/method names to new names + 2. A mapping of old class/method/variable names AND old file paths to their new + names or new locations + + The mapping may contain two kinds of entries: + - Identifier renames: the value is a Java identifier (e.g. `computeTotal`). + Replace every occurrence of the old simple name in the problem statement with + the new one wherever it appears (class names, method calls, variable mentions, + fully-qualified references, etc.). + - File / package moves: the value looks like a filesystem path or directory + (contains `/` or `\`). The corresponding source file was relocated, which + typically changes its Java package. Update any fully-qualified class + references, `import` statements, or package mentions in the problem statement + to reflect the new package implied by the new directory. Your task: - - Update the problem statement and interface description to use the NEW names + - Update the problem statement to use the NEW names and NEW packages - Keep the meaning and structure exactly the same - - Only change the class, method, and variable names according to the mapping - Preserve all formatting, punctuation, and sentence structure - If a name doesn't appear in the mapping, leave it unchanged @@ -107,7 +118,7 @@ class MetamorphicTextTransformer( - Do NOT add new information - Do NOT remove information - Do NOT rephrase or rewrite the content - - ONLY update the names that appear in the rename mapping + - ONLY update the names and packages indicated by the rename mapping """.trimIndent()) } @@ -116,20 +127,25 @@ class MetamorphicTextTransformer( text(problemStatement) text("\n\n") - text("## Original Interface Description\n") - text(interfaceDescription) - text("\n\n") + val (moves, renames) = renameMap.entries.partition { (_, value) -> + value.contains('/') || value.contains('\\') + } - text("## Rename Mapping (OldName -> NewName)\n") - renameMap.forEach { (old, new) -> - // Extract simple class name from fully qualified name + text("## Identifier renames (OldSimpleName -> NewSimpleName)\n") + renames.forEach { (old, new) -> val oldSimple = old.substringAfterLast('.') - val newSimple = new - text("- $oldSimple -> $newSimple\n") + text("- $oldSimple -> $new\n") + } + + if (moves.isNotEmpty()) { + text("\n## File/package moves (OldPath -> NewDirectory)\n") + moves.forEach { (old, new) -> + text("- $old -> $new\n") + } } text("\n") - text("Now, update the problem statement and interface description with the new names.") + text("Now, update the problem statement with the new names and packages.") } } } @@ -141,20 +157,18 @@ class MetamorphicTextTransformer( * Usage: * ./gradlew transformMetamorphicTexts \ * -PproblemStatement="..." \ - * -PinterfaceDesc="..." \ * -PmemoryFile="/path/to/memory.json" \ * -PoutputFile="/path/to/output.json" */ suspend fun main(args: Array) { - if (args.size != 4) { - println("Usage: transformMetamorphicTexts ") + if (args.size != 3) { + println("Usage: transformMetamorphicTexts ") return } val problemStatement = args[0] - val interfaceDesc = args[1] - val memoryFile = args[2] - val outputFile = args[3] + val memoryFile = args[1] + val outputFile = args[2] try { // Initialize LLM (using GPT-4 via Grazie) @@ -173,7 +187,6 @@ suspend fun main(args: Array) { println("Transforming texts using memory file: $memoryFile") val result = transformer.transformTexts( problemStatement = problemStatement, - interfaceDescription = interfaceDesc, memoryFilePath = memoryFile ) From b5a2289f554fc20cdb6c18608a7dc2297453acaa Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 25 Apr 2026 17:14:32 +0200 Subject: [PATCH 24/67] refactor: replace `memoryDir` with `memoryFilepath` across configuration and persistent memory components --- codecocoon.example.yml | 10 +-- .../codecocoonplugin/memory/Memory.kt | 2 +- .../config/CodeCocoonConfig.kt | 4 +- .../codecocoonplugin/config/ConfigLoader.kt | 31 ++++---- .../memory/PersistentMemory.kt | 72 ++++--------------- .../services/TransformationService.kt | 6 +- 6 files changed, 40 insertions(+), 85 deletions(-) diff --git a/codecocoon.example.yml b/codecocoon.example.yml index d295c19..7d20df4 100644 --- a/codecocoon.example.yml +++ b/codecocoon.example.yml @@ -4,12 +4,12 @@ projectRoot: "/path/to/project/root" # Optional: limit transformations to these files (relative to the root). Leave empty to target the entire project files: [] -# Optional: directory where memory files are stored (for deterministic rename transformations) -# If not specified, defaults to '.codecocoon-memory' in the same directory as this config file +# Optional: full path to the JSON memory file (for deterministic rename transformations) +# If not specified, defaults to '.codecocoon-memory.json' in the same directory as this config file # Can be: -# - Absolute path: "/absolute/path/to/memory" -# - Relative path: "my-memory-dir" (relative to this config file's directory) -memoryDir: ".codecocoon-memory" +# - Absolute path: "/absolute/path/to/memory.json" +# - Relative path: "my-memory.json" (relative to this config file's directory) +memoryFilepath: ".codecocoon-memory.json" # The transformation pipeline. Order matters. Each transformation has: # - id: unique identifier diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/Memory.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/Memory.kt index 7b0bf59..48da4e9 100644 --- a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/Memory.kt +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/Memory.kt @@ -7,7 +7,7 @@ package com.github.pderakhshanfar.codecocoonplugin.memory * Use with `.use {}` blocks to guarantee data is saved: * * ```kotlin - * PersistentMemory(projectName, memoryDir).use { memory -> + * PersistentMemory(memoryFilepath).use { memory -> * memory.put("key", "value") * // memory.save() called automatically on close * } diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/CodeCocoonConfig.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/CodeCocoonConfig.kt index 57c2ff1..5f8b3c9 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/CodeCocoonConfig.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/CodeCocoonConfig.kt @@ -13,8 +13,8 @@ import com.intellij.openapi.vfs.VirtualFile val files: List = emptyList(), /** Ordered list of transformations to execute */ val transformations: List = emptyList(), - /** Directory where memory files are stored (resolved to absolute path by ConfigLoader) */ - val memoryDir: String, + /** Full path to the memory JSON file (resolved to absolute path by ConfigLoader) */ + val memoryFilepath: String, ) /** diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/ConfigLoader.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/ConfigLoader.kt index f569daf..a0834a6 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/ConfigLoader.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/ConfigLoader.kt @@ -48,8 +48,8 @@ object ConfigLoader { TransformationConfig(id = id, config = cfg) } - // Resolve memory directory - val memoryDir = resolveMemoryDir(root["memoryDir"]?.toString()) + // Resolve memory file path + val memoryFilepath = resolveMemoryFilepath(root["memoryFilepath"]?.toString()) // if projectRoot is present, try to search for the corresponding virtual file val projectRootFile = projectRoot?.refreshAndFindVirtualFile() @@ -59,25 +59,25 @@ object ConfigLoader { projectRootFile = projectRootFile, files = files, transformations = transformations, - memoryDir = memoryDir, + memoryFilepath = memoryFilepath, ) } } /** - * Resolves the memory directory to an absolute path string. + * Resolves the memory JSON file path to an absolute path string. * - * If [memoryDirPath] is provided: + * If [memoryFilepath] is provided: * - If absolute: use as-is * - If relative: resolve relative to config file's parent directory * - * If [memoryDirPath] is null: - * - Default to ".codecocoon-memory" in config file's parent directory + * If [memoryFilepath] is null: + * - Default to ".codecocoon-memory.json" in config file's parent directory * - * @param memoryDirPath Optional memory directory path from YAML - * @return Resolved absolute path for memory directory + * @param memoryFilepath Optional memory file path from YAML + * @return Resolved absolute path to the memory JSON file */ - private fun resolveMemoryDir(memoryDirPath: String?): String { + private fun resolveMemoryFilepath(memoryFilepath: String?): String { val configPath = System.getProperty("codecocoon.config") ?: throw IllegalStateException("codecocoon.config system property not set") @@ -85,16 +85,17 @@ object ConfigLoader { val configParentDir = configFile.parentFile ?: throw IllegalStateException("Config file has no parent directory: $configPath") - return if (memoryDirPath != null) { - val memoryFile = File(memoryDirPath) + return if (memoryFilepath != null) { + val memoryFile = File(memoryFilepath) if (memoryFile.isAbsolute) { memoryFile.canonicalPath } else { - File(configParentDir, memoryDirPath).canonicalPath + File(configParentDir, memoryFilepath).canonicalPath } } else { - // Default to .codecocoon-memory in config parent directory - File(configParentDir, ".codecocoon-memory").canonicalPath + // Default to .codecocoon-memory.json in config parent directory + val memoryDir = File(configParentDir, ".codecocoon-memory") + File(memoryDir, "memory.json").canonicalPath } } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/PersistentMemory.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/PersistentMemory.kt index 6688d86..97f2657 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/PersistentMemory.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/PersistentMemory.kt @@ -11,45 +11,31 @@ import kotlin.io.path.* /** * File-based persistent storage implementation of [Memory] interface. * - * Stores key-value pairs as JSON in a file within the specified directory. - * Files are organized by project name to allow tracking multiple projects independently. - * - * **Storage Model:** - * Each project gets its own JSON file containing all key-value pairs for that project. - * For example, given `memoryDirPath = "/path/to/memory"` and three projects: - * - `PersistentMemory("project-A", memoryDirPath)` → `/path/to/memory/project-A.json` - * - `PersistentMemory("project-B", memoryDirPath)` → `/path/to/memory/project-B.json` - * - `PersistentMemory("nested/project-C", memoryDirPath)` → `/path/to/memory/nested_project-C.json` (sanitized) - * - * Each JSON file contains all entries for that project, persisted on [save] or [close]. + * Stores key-value pairs as JSON in a single file at the path provided to the constructor. + * The caller decides exactly where the memory file lives — there is no project-name + * composition or per-project file partitioning. * * **Thread Safety:** This implementation is not thread-safe. Use external synchronization * if accessing from multiple threads. * * **Usage:** * ```kotlin - * PersistentMemory("myProject", "/path/to/memory").use { memory -> + * PersistentMemory("/path/to/memory.json").use { memory -> * memory.put("key", "value") * memory.get("key") // returns "value" * } // automatically saves on close * ``` * - * @param projectName The name of the project (used for the memory filename) - * @param memoryDirPath The directory path where memory files should be stored + * @param memoryFilepath Full path to the JSON memory file (created if missing) */ -class PersistentMemory(private val projectName: String, memoryDirPath: String) : Memory { +class PersistentMemory(private val memoryFilepath: String) : Memory { private val logger = thisLogger().withStdout() private val memoryFile: Path = run { - // Sanitize project name for use in filename - val sanitizedName = sanitizeProjectName(projectName) - - // Convert path to Path and ensure memory directory exists - val memoryDir = Path(memoryDirPath) - memoryDir.createDirectories() - - memoryDir.resolve("$sanitizedName.json") + val path = Path(memoryFilepath) + path.parent?.createDirectories() + path } private var state: MemoryState = loadFromDisk(from = memoryFile) @@ -78,53 +64,25 @@ class PersistentMemory(private val projectName: String, memoryDirPath: String) : override fun save() { val jsonString = json.encodeToString(state) memoryFile.writeText(jsonString) - logger.info(" ↳ Successfully saved memory for project '$projectName' (${state.entries.size} entries)") + logger.info(" ↳ Successfully saved memory to '$memoryFilepath' (${state.entries.size} entries)") } override fun size(): Int = state.entries.size /** * Loads memory data from disk, or creates a new empty memory if the file doesn't exist. - * Throws on JSON parse errors or project name mismatches. + * Throws on JSON parse errors. * * @param from The path to the memory file to load from */ private fun loadFromDisk(from: Path): MemoryState { if (!from.exists()) { - logger.info(" • No existing memory file found for project '$projectName', creating new memory") - return MemoryState(projectName, mutableMapOf()) + logger.info(" • No existing memory file at '$memoryFilepath', creating new memory") + return MemoryState(mutableMapOf()) } val jsonString = from.readText() - val loaded = json.decodeFromString(jsonString) - - // Verify project name matches - if (loaded.projectName != projectName) { - throw IllegalStateException( - "Memory file project name mismatch: expected '$projectName', found '${loaded.projectName}'. " + - "Memory file: ${from.absolutePathString()}" - ) - } - - return loaded - } - - /** - * Sanitizes a project name to be safe for use in a filename. - * Throws if the project name is blank or becomes blank after sanitization. - */ - private fun sanitizeProjectName(name: String): String { - val sanitized = name - .replace(Regex("[^a-zA-Z0-9_-]"), "_") - .take(100) // Limit length to avoid filesystem issues - - if (sanitized.isBlank()) { - throw IllegalArgumentException( - "Project name '$name' contains only invalid characters or is blank." - ) - } - - return sanitized + return json.decodeFromString(jsonString) } companion object { @@ -138,11 +96,9 @@ class PersistentMemory(private val projectName: String, memoryDirPath: String) : /** * Data class representing the persistent memory file structure. * - * @property projectName The name of the project this memory belongs to * @property entries Map from key to value */ @Serializable private data class MemoryState( - val projectName: String, val entries: MutableMap ) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt index 1caa7ad..6da0c8a 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt @@ -20,7 +20,6 @@ import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileVisitor -import java.io.File /** * Application-level service responsible for managing metamorphic transformations @@ -133,9 +132,8 @@ class TransformationService { // Create a global memory instance for the entire project // Memory is automatically saved via .use {} when the block exits - val projectName = project.basePath?.let { File(it).name } ?: project.name - PersistentMemory(projectName, config.memoryDir).use { memory -> - logger.info("[TransformationService] Created global memory for project '$projectName'") + PersistentMemory(config.memoryFilepath).use { memory -> + logger.info("[TransformationService] Created global memory at '${config.memoryFilepath}'") var successCount = 0 var failureCount = 0 From 374fa4b544bb21c09631a4dbff82bd2ea7f38397 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 25 Apr 2026 17:37:24 +0200 Subject: [PATCH 25/67] feat: add `ParaphraseTextTransformer` for problem statement rewrites and CLI support via `RewriteProblemStatementStarter` --- build.gradle.kts | 18 +++ .../RewriteProblemStatementStarter.kt | 68 +++++++++ .../services/ParaphraseTextTransformer.kt | 140 ++++++++++++++++++ src/main/resources/META-INF/plugin.xml | 2 + 4 files changed, 228 insertions(+) create mode 100644 src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/RewriteProblemStatementStarter.kt create mode 100644 src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/ParaphraseTextTransformer.kt diff --git a/build.gradle.kts b/build.gradle.kts index 0d077eb..75fac2a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -258,5 +258,23 @@ intellijPlatformTesting { ) } } + + // Custom task for paraphrasing the problem statement (semantic-preserving rewrite) + register("rewriteProblemStatement") { + task { + args(listOf("rewrite-problem-statement")) + + val problemStatement = project.findProperty("problemStatement") as? String ?: "" + val outputFile = project.findProperty("outputFile") as? String ?: "" + + jvmArgs( + "-Xmx4G", + "-Djava.awt.headless=true", + "--add-exports", "java.base/jdk.internal.vm=ALL-UNNAMED", + "-Drewrite.problemStatement=${problemStatement}", + "-Drewrite.outputFile=${outputFile}", + ) + } + } } } diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/RewriteProblemStatementStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/RewriteProblemStatementStarter.kt new file mode 100644 index 0000000..2c4be09 --- /dev/null +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/RewriteProblemStatementStarter.kt @@ -0,0 +1,68 @@ +package com.github.pderakhshanfar.codecocoonplugin.appstarter + +import ai.koog.prompt.executor.clients.openai.OpenAIModels +import com.github.pderakhshanfar.codecocoonplugin.common.LLM +import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout +import com.github.pderakhshanfar.codecocoonplugin.services.ParaphraseTextTransformer +import com.intellij.openapi.application.ApplicationStarter +import com.intellij.openapi.diagnostic.thisLogger +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import kotlin.system.exitProcess + +/** + * Application starter for paraphrasing a problem statement in headless mode. + * Entry point when the IDE is launched with the 'rewrite-problem-statement' command. + */ +class RewriteProblemStatementStarter : ApplicationStarter { + override val requiredModality: Int = ApplicationStarter.NOT_IN_EDT + private val logger = thisLogger().withStdout() + + override fun main(args: List) { + val problemStatement = System.getProperty("rewrite.problemStatement") ?: "" + val outputFile = System.getProperty("rewrite.outputFile") ?: "" + + if (problemStatement.isEmpty() || outputFile.isEmpty()) { + logger.error("[RewriteProblemStatement] Missing required parameters") + logger.error("[RewriteProblemStatement] Required system properties: rewrite.problemStatement, rewrite.outputFile") + exitProcess(1) + } + + val token = System.getenv("GRAZIE_TOKEN") + if (token == null) { + logger.error("[RewriteProblemStatement] GRAZIE_TOKEN environment variable not set") + exitProcess(1) + } + + runBlocking { + try { + logger.info("[RewriteProblemStatement] Starting problem-statement paraphrase") + + val llm = LLM.fromGrazie( + model = OpenAIModels.Chat.GPT5Mini, + token = token + ) + + val transformer = ParaphraseTextTransformer(llm) + + val result = transformer.rewrite(problemStatement) + + if (result != null) { + val json = Json.encodeToString(result) + File(outputFile).writeText(json) + logger.info("[RewriteProblemStatement] SUCCESS: Paraphrased problem statement written to: $outputFile") + exitProcess(0) + } else { + logger.error("[RewriteProblemStatement] ERROR: Failed to paraphrase problem statement") + exitProcess(1) + } + } catch (e: Exception) { + logger.error("[RewriteProblemStatement] ERROR: ${e.message}", e) + e.printStackTrace(System.err) + exitProcess(1) + } + } + } +} diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/ParaphraseTextTransformer.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/ParaphraseTextTransformer.kt new file mode 100644 index 0000000..6e26f8a --- /dev/null +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/ParaphraseTextTransformer.kt @@ -0,0 +1,140 @@ +package com.github.pderakhshanfar.codecocoonplugin.services + +import ai.koog.prompt.dsl.Prompt +import com.github.pderakhshanfar.codecocoonplugin.common.LLM +import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout +import com.intellij.openapi.diagnostic.thisLogger +import kotlinx.serialization.Serializable + +/** + * Rewrites a problem statement to look surface-different while preserving exact semantics. + * Used in the eval pipeline AFTER [MetamorphicTextTransformer] has synced renames/moves. + */ +class ParaphraseTextTransformer( + private val llm: LLM, +) { + private val logger = thisLogger().withStdout() + + @Serializable + data class ParaphrasedText( + val problemStatement: String, + ) + + /** + * Asks the LLM to paraphrase [problemStatement]. Returns null if the LLM call fails. + */ + suspend fun rewrite(problemStatement: String): ParaphrasedText? { + if (problemStatement.isBlank()) { + logger.warn("WARNING: Empty problem statement passed to paraphraser") + return ParaphrasedText(problemStatement) + } + + val prompt = createRewritePrompt(problemStatement) + logger.info("Created paraphrase prompt:\n'''$prompt\n'''") + + return llm.structuredRequest( + prompt = prompt, + maxRetries = 3, + maxFixingAttempts = 2, + ) + } + + private fun createRewritePrompt(problemStatement: String): Prompt { + return Prompt.build("paraphrase-problem-statement") { + system { + text(""" + You are a technical-documentation paraphrasing assistant. + + You will be given a problem statement. Your task is to AGGRESSIVELY + rewrite it so the result looks SUBSTANTIALLY different on the surface + while remaining a strict semantic synonym — every requirement, fact, + and constraint preserved exactly. + + Be bold with the rewrite. A near-copy of the input is a FAILURE. Aim + for a high-effort rewrite that a reader would not recognise as the + same prose at first glance, yet a domain expert would confirm carries + the same intent. + + HARD CONSTRAINTS — preserve verbatim, never alter: + - All identifiers: class names, method names, variable names, package + names, file paths, command-line flags, environment variables, URLs, + version strings. Do NOT rename, translate, or pluralise them. + - All code-like tokens inside `backticks` and inside fenced code + blocks (```...```). Do not edit, reorder, or reformat code fences + or their contents. + - All numbers, units, and concrete values (e.g. "5 retries", + "200 OK", "UTF-8"). + - Markdown structural elements: headings, bullet lists, numbered + lists, and tables. Their COUNT and the order of their items must + stay the same; you may rephrase the prose inside each item, but do + not add, drop, merge, or split items, and do not change heading + levels. + + REQUIRED SURFACE CHANGES — apply several of these, not just one: + 1. Lexical: replace ordinary verbs, nouns, adjectives, and connectors + with synonyms or near-synonyms ("provides" → "exposes", + "responsible for" → "in charge of", "must continue to" → "are + still required to"). Do this for the MAJORITY of non-identifier + content words. + 2. Syntactic: reshape sentences. Convert active ↔ passive voice, + swap subject/object framing, hoist subordinate clauses to the + front, turn "X does Y so that Z" into "Z requires that X does Y", + and similar. At least half of the sentences should differ in + structure from their original counterparts. + 3. Granularity: split long sentences into shorter ones, or fuse two + short sentences into one — wherever it improves rhythm and the + meaning is preserved. + 4. Sentence ordering WITHIN A PARAGRAPH: you may reorder sentences + within the same paragraph if it preserves logical flow. Do NOT + move sentences across paragraph boundaries or across markdown + sections. + 5. Register tightening: keep tone neutral and technical; trim throat- + clearing phrases ("In order to" → "To") where it does not change + meaning. + + FORBIDDEN: + - Do NOT add facts, examples, qualifications, or reasoning the + original did not contain. + - Do NOT remove facts, examples, qualifications, or reasoning the + original DID contain. + - Do NOT introduce ambiguity, hedging, or vagueness that the + original did not have ("must" stays "must"; "may" stays "may"). + - Do NOT translate to another natural language. + - Do NOT comment on the rewrite, prefix it, or wrap it in extra + narration. + + SELF-CHECK before responding: + - Could a reader infer any requirement that was not in the original? + If yes, revise. + - Could a reader miss any requirement that was in the original? If + yes, revise. + - Does at least 60% of the prose read differently (different word + choice or sentence shape) from the input? If no, rewrite more + aggressively. + + Output: a JSON object with a single field `problemStatement` holding + the rewritten text: + ```json + { + "problemStatement": "..." + } + ``` + """.trimIndent()) + } + + user { + text("## Original Problem Statement:") + + newline() + text("'''") + newline() + text(problemStatement) + newline() + text("'''") + + text("\n\n") + text("Now produce the paraphrased problem statement following the given rules.") + } + } + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 6da7aff..2b798c1 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -15,5 +15,7 @@ id="codecocoon"/> + From 078836061b8f872d6b7e79d6ff611d25e40e323f Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 25 Apr 2026 19:23:27 +0200 Subject: [PATCH 26/67] feat: add `BenchmarkInstanceIO` for JSON-based benchmark manipulation and integrate with CLI starters - Introduced `BenchmarkInstanceIO` for JSON parsing and transformation of benchmark records. - Updated `RewriteProblemStatementStarter` and `TransformTextsStarter` to process `{title, body}` pairs and `resolved_issues`. - Added `TextBlock` data class to support coherent updates across textual fields. - Enhanced Gradle tasks `rewriteProblemStatement` and `transformMetamorphicTexts` with improved input/output handling. --- build.gradle.kts | 19 +- .../appstarter/BenchmarkInstanceIO.kt | 102 +++++++++ .../RewriteProblemStatementStarter.kt | 45 ++-- .../appstarter/TransformTextsStarter.kt | 55 +++-- .../services/MetamorphicTextTransformer.kt | 204 ++++++++---------- .../services/ParaphraseTextTransformer.kt | 125 ++++++----- .../codecocoonplugin/services/TextBlock.kt | 12 ++ 7 files changed, 341 insertions(+), 221 deletions(-) create mode 100644 src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/BenchmarkInstanceIO.kt create mode 100644 src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TextBlock.kt diff --git a/build.gradle.kts b/build.gradle.kts index 75fac2a..827b067 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -236,42 +236,43 @@ intellijPlatformTesting { } } - // Custom task for metamorphic text transformation (runs within IntelliJ platform) + // Custom task for metamorphic text transformation (runs within IntelliJ platform). + // Reads a benchmark-record JSON file, applies rename/move sync block by block, + // and writes a same-schema JSON file. register("transformMetamorphicTexts") { task { - // Program arguments for the transformation args(listOf("transform-texts")) - // Pass parameters via system properties val memoryFile = project.findProperty("memoryFile") as? String ?: "" - val problemStatement = project.findProperty("problemStatement") as? String ?: "" + val inputFile = project.findProperty("inputFile") as? String ?: "" val outputFile = project.findProperty("outputFile") as? String ?: "" - // JVM arguments jvmArgs( "-Xmx4G", "-Djava.awt.headless=true", "--add-exports", "java.base/jdk.internal.vm=ALL-UNNAMED", "-Dtransform.memoryFile=${memoryFile}", - "-Dtransform.problemStatement=${problemStatement}", + "-Dtransform.inputFile=${inputFile}", "-Dtransform.outputFile=${outputFile}", ) } } - // Custom task for paraphrasing the problem statement (semantic-preserving rewrite) + // Custom task for paraphrasing benchmark-record texts (semantic-preserving rewrite). + // Reads a benchmark-record JSON file, paraphrases each {title, body} block, + // and writes a same-schema JSON file. register("rewriteProblemStatement") { task { args(listOf("rewrite-problem-statement")) - val problemStatement = project.findProperty("problemStatement") as? String ?: "" + val inputFile = project.findProperty("inputFile") as? String ?: "" val outputFile = project.findProperty("outputFile") as? String ?: "" jvmArgs( "-Xmx4G", "-Djava.awt.headless=true", "--add-exports", "java.base/jdk.internal.vm=ALL-UNNAMED", - "-Drewrite.problemStatement=${problemStatement}", + "-Drewrite.inputFile=${inputFile}", "-Drewrite.outputFile=${outputFile}", ) } diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/BenchmarkInstanceIO.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/BenchmarkInstanceIO.kt new file mode 100644 index 0000000..8364afc --- /dev/null +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/BenchmarkInstanceIO.kt @@ -0,0 +1,102 @@ +package com.github.pderakhshanfar.codecocoonplugin.appstarter + +import com.github.pderakhshanfar.codecocoonplugin.services.TextBlock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Helpers shared by [TransformTextsStarter] and [RewriteProblemStatementStarter] for + * reading/writing benchmark-record JSON files of the schema: + * + * ```json + * { + * "title": "...", + * "body": "...", + * "resolved_issues": [ + * { "number": 1, "title": "...", "body": "..." } + * ] + * } + * ``` + * + * The starters mutate a [JsonObject] in place rather than round-tripping through a + * typed data class so any extra keys present in the benchmark record (or inside each + * resolved issue) are preserved verbatim on output. + * + * Processing happens in BLOCKS — one transform call per `{title, body}` pair — so a + * single LLM round-trip can keep title and body internally consistent. The main record + * is one block; each resolved issue is another. + */ +internal object BenchmarkInstanceIO { + + val json: Json = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + } + + /** + * Walks [obj], applying [transform] once to the main `{title, body}` block and once + * to each `resolved_issues[i].{title, body}` block. When [transform] returns null + * for a block, the original block is kept (so a single failure does not sink the + * record). + * + * Keys other than `title`, `body`, and `resolved_issues` (and, inside each issue, + * keys other than `title` / `body`) pass through verbatim. Output title/body are + * only emitted when the corresponding key was present in the input. + */ + suspend fun transformInstance( + obj: JsonObject, + transform: suspend (TextBlock) -> TextBlock?, + ): JsonObject { + val hasTitle = obj.containsKey("title") + val hasBody = obj.containsKey("body") + val mainBlock = TextBlock( + title = obj["title"]?.jsonPrimitive?.contentOrNull ?: "", + body = obj["body"]?.jsonPrimitive?.contentOrNull ?: "", + ) + val mainResult = transform(mainBlock) ?: mainBlock + + val newResolvedIssues = obj["resolved_issues"]?.jsonArray?.let { arr -> + buildJsonArray { + for (element in arr) { + val issue = element.jsonObject + val issueHasTitle = issue.containsKey("title") + val issueHasBody = issue.containsKey("body") + val issueBlock = TextBlock( + title = issue["title"]?.jsonPrimitive?.contentOrNull ?: "", + body = issue["body"]?.jsonPrimitive?.contentOrNull ?: "", + ) + val issueResult = transform(issueBlock) ?: issueBlock + + add(buildJsonObject { + for ((k, v) in issue) { + when (k) { + "title" -> if (issueHasTitle) put(k, JsonPrimitive(issueResult.title)) + "body" -> if (issueHasBody) put(k, JsonPrimitive(issueResult.body)) + else -> put(k, v) + } + } + }) + } + } + } + + return buildJsonObject { + for ((k, v) in obj) { + when (k) { + "title" -> if (hasTitle) put(k, JsonPrimitive(mainResult.title)) + "body" -> if (hasBody) put(k, JsonPrimitive(mainResult.body)) + "resolved_issues" -> put(k, newResolvedIssues ?: v) + else -> put(k, v) + } + } + } + } +} diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/RewriteProblemStatementStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/RewriteProblemStatementStarter.kt index 2c4be09..eb7de52 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/RewriteProblemStatementStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/RewriteProblemStatementStarter.kt @@ -7,13 +7,17 @@ import com.github.pderakhshanfar.codecocoonplugin.services.ParaphraseTextTransfo import com.intellij.openapi.application.ApplicationStarter import com.intellij.openapi.diagnostic.thisLogger import kotlinx.coroutines.runBlocking -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import java.io.File import kotlin.system.exitProcess /** - * Application starter for paraphrasing a problem statement in headless mode. + * Application starter for paraphrasing the textual fields of a benchmark record in + * headless mode. Reads a benchmark-record JSON file, paraphrases the main + * `{title, body}` block and each `resolved_issues[i].{title, body}` block (one LLM + * call per block so title and body stay coherent), and writes a same-schema JSON file. + * * Entry point when the IDE is launched with the 'rewrite-problem-statement' command. */ class RewriteProblemStatementStarter : ApplicationStarter { @@ -21,12 +25,12 @@ class RewriteProblemStatementStarter : ApplicationStarter { private val logger = thisLogger().withStdout() override fun main(args: List) { - val problemStatement = System.getProperty("rewrite.problemStatement") ?: "" + val inputFile = System.getProperty("rewrite.inputFile") ?: "" val outputFile = System.getProperty("rewrite.outputFile") ?: "" - if (problemStatement.isEmpty() || outputFile.isEmpty()) { + if (inputFile.isEmpty() || outputFile.isEmpty()) { logger.error("[RewriteProblemStatement] Missing required parameters") - logger.error("[RewriteProblemStatement] Required system properties: rewrite.problemStatement, rewrite.outputFile") + logger.error("[RewriteProblemStatement] Required system properties: rewrite.inputFile, rewrite.outputFile") exitProcess(1) } @@ -38,26 +42,31 @@ class RewriteProblemStatementStarter : ApplicationStarter { runBlocking { try { - logger.info("[RewriteProblemStatement] Starting problem-statement paraphrase") + logger.info("[RewriteProblemStatement] Starting paraphrase") + logger.info("[RewriteProblemStatement] Input file: $inputFile") val llm = LLM.fromGrazie( model = OpenAIModels.Chat.GPT5Mini, - token = token + token = token, ) - val transformer = ParaphraseTextTransformer(llm) - val result = transformer.rewrite(problemStatement) + val inputJson = BenchmarkInstanceIO.json + .parseToJsonElement(File(inputFile).readText()) + .jsonObject - if (result != null) { - val json = Json.encodeToString(result) - File(outputFile).writeText(json) - logger.info("[RewriteProblemStatement] SUCCESS: Paraphrased problem statement written to: $outputFile") - exitProcess(0) - } else { - logger.error("[RewriteProblemStatement] ERROR: Failed to paraphrase problem statement") - exitProcess(1) + val outputJson = BenchmarkInstanceIO.transformInstance(inputJson) { block -> + try { + transformer.rewriteBlock(block) + } catch (e: Exception) { + logger.error("[RewriteProblemStatement] Block-level paraphrase failed; keeping original block: ${e.message}", e) + null + } } + + File(outputFile).writeText(BenchmarkInstanceIO.json.encodeToString(JsonObject.serializer(), outputJson)) + logger.info("[RewriteProblemStatement] SUCCESS: Paraphrased record written to: $outputFile") + exitProcess(0) } catch (e: Exception) { logger.error("[RewriteProblemStatement] ERROR: ${e.message}", e) e.printStackTrace(System.err) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/TransformTextsStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/TransformTextsStarter.kt index c78def2..6067e4c 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/TransformTextsStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/TransformTextsStarter.kt @@ -7,14 +7,18 @@ import com.github.pderakhshanfar.codecocoonplugin.services.MetamorphicTextTransf import com.intellij.openapi.application.ApplicationStarter import com.intellij.openapi.diagnostic.thisLogger import kotlinx.coroutines.runBlocking -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import java.io.File import kotlin.system.exitProcess /** * Application starter for running text transformation in headless mode. - * This is the entry point when the IDE is launched with the 'transform-texts' command. + * Reads a benchmark-record JSON file, applies rename/move sync to the main + * `{title, body}` block and each `resolved_issues[i].{title, body}` block (one LLM + * call per block so title and body stay coherent), and writes a same-schema JSON file. + * + * Entry point when the IDE is launched with the 'transform-texts' command. */ class TransformTextsStarter : ApplicationStarter { override val requiredModality: Int = ApplicationStarter.NOT_IN_EDT @@ -22,12 +26,12 @@ class TransformTextsStarter : ApplicationStarter { override fun main(args: List) { val memoryFile = System.getProperty("transform.memoryFile") ?: "" - val problemStatement = System.getProperty("transform.problemStatement") ?: "" + val inputFile = System.getProperty("transform.inputFile") ?: "" val outputFile = System.getProperty("transform.outputFile") ?: "" - if (memoryFile.isEmpty() || problemStatement.isEmpty() || outputFile.isEmpty()) { + if (memoryFile.isEmpty() || inputFile.isEmpty() || outputFile.isEmpty()) { logger.error("[TransformTexts] Missing required parameters") - logger.error("[TransformTexts] Required system properties: transform.memoryFile, transform.problemStatement, transform.outputFile") + logger.error("[TransformTexts] Required system properties: transform.memoryFile, transform.inputFile, transform.outputFile") exitProcess(1) } @@ -41,28 +45,41 @@ class TransformTextsStarter : ApplicationStarter { try { logger.info("[TransformTexts] Starting text transformation") logger.info("[TransformTexts] Memory file: $memoryFile") + logger.info("[TransformTexts] Input file: $inputFile") val llm = LLM.fromGrazie( model = OpenAIModels.Chat.GPT5Mini, - token = token + token = token, ) - val transformer = MetamorphicTextTransformer(llm) - val result = transformer.transformTexts( - problemStatement = problemStatement, - memoryFilePath = memoryFile - ) + val renameMap = transformer.loadRenameMap(memoryFile) + if (renameMap == null) { + logger.error("[TransformTexts] Failed to load rename map from '$memoryFile'") + exitProcess(1) + } - if (result != null) { - val json = Json.encodeToString(result) - File(outputFile).writeText(json) - logger.info("[TransformTexts] SUCCESS: Transformed texts written to: $outputFile") - exitProcess(0) + val inputJson = BenchmarkInstanceIO.json + .parseToJsonElement(File(inputFile).readText()) + .jsonObject + + val outputJson = if (renameMap.isEmpty()) { + logger.warn("[TransformTexts] Rename map is empty; copying input verbatim") + inputJson } else { - logger.error("[TransformTexts] ERROR: Failed to transform texts") - exitProcess(1) + BenchmarkInstanceIO.transformInstance(inputJson) { block -> + try { + transformer.transformBlock(block, renameMap) + } catch (e: Exception) { + logger.error("[TransformTexts] Block-level transformation failed; keeping original block: ${e.message}", e) + null + } + } } + + File(outputFile).writeText(BenchmarkInstanceIO.json.encodeToString(JsonObject.serializer(), outputJson)) + logger.info("[TransformTexts] SUCCESS: Transformed record written to: $outputFile") + exitProcess(0) } catch (e: Exception) { logger.error("[TransformTexts] ERROR: ${e.message}", e) e.printStackTrace(System.err) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt index fe20735..24c042e 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/MetamorphicTextTransformer.kt @@ -1,7 +1,6 @@ package com.github.pderakhshanfar.codecocoonplugin.services import ai.koog.prompt.dsl.Prompt -import ai.koog.prompt.executor.clients.openai.OpenAIModels import com.github.pderakhshanfar.codecocoonplugin.common.LLM import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout import com.intellij.openapi.diagnostic.thisLogger @@ -9,13 +8,12 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.encodeToString import java.io.File /** - * Transforms problem statements for metamorphic test instances by updating class, method, - * and variable names — and package references when files were moved — according to the - * rename memory. + * Updates a [TextBlock] (`{title, body}` pair) so class/method/variable names and + * package references reflect a rename memory file produced by the renaming/file-moving + * transformations. One LLM call per block keeps title and body internally consistent. */ class MetamorphicTextTransformer( private val llm: LLM, @@ -23,23 +21,16 @@ class MetamorphicTextTransformer( private val logger = thisLogger().withStdout() @Serializable - data class TransformedTexts( - val problemStatement: String, + private data class TransformedBlock( + val title: String, + val body: String, ) /** - * Transforms the problem statement using the memory file. - * - * @param problemStatement The original problem statement - * @param memoryFilePath Path to the memory JSON file containing class/method renames - * and (optionally) file-move entries - * @return TransformedTexts with updated names + * Loads the rename map from [memoryFilePath]. Returns null when the file is missing + * or malformed; an empty map when the file contains no entries. */ - suspend fun transformTexts( - problemStatement: String, - memoryFilePath: String, - ): TransformedTexts? { - // Load memory file + fun loadRenameMap(memoryFilePath: String): Map? { val memoryFile = File(memoryFilePath) if (!memoryFile.exists()) { logger.error("ERROR: Memory file not found: $memoryFilePath") @@ -47,84 +38,110 @@ class MetamorphicTextTransformer( } val memoryJson = Json.parseToJsonElement(memoryFile.readText()) - val entries = memoryJson.jsonObject["entries"]?.jsonObject - - if (entries == null) { + val entries = memoryJson.jsonObject["entries"]?.jsonObject ?: run { logger.error("ERROR: No 'entries' field found in memory file") return null } - // Build a mapping of old names to new names - val renameMap = mutableMapOf() - entries.entries.forEach { (oldName, newName) -> - renameMap[oldName] = newName.jsonPrimitive.content + return entries.entries.associate { (oldName, newName) -> + oldName to newName.jsonPrimitive.content } + } - if (renameMap.isEmpty()) { - logger.warn("WARNING: No rename entries found in memory file") - return TransformedTexts(problemStatement) - } + /** + * Updates [block]'s title and body together. Returns [block] verbatim when the + * rename map is empty or both fields are blank. Returns null on LLM failure (the + * caller decides whether to fall back). + */ + suspend fun transformBlock( + block: TextBlock, + renameMap: Map, + ): TextBlock? { + if (renameMap.isEmpty()) return block + if (block.title.isBlank() && block.body.isBlank()) return block - // Create the prompt - val prompt = createTransformationPrompt( - problemStatement = problemStatement, - renameMap = renameMap - ) - logger.info("Created prompt:\n'''$prompt\n'''") + val prompt = createTransformationPrompt(block = block, renameMap = renameMap) + logger.info("Created metamorphic prompt:\n'''$prompt\n'''") - // Call LLM - val result = llm.structuredRequest( + val result = llm.structuredRequest( prompt = prompt, maxRetries = 3, - maxFixingAttempts = 2 - ) + maxFixingAttempts = 2, + ) ?: return null - return result + return TextBlock(title = result.title, body = result.body) } private fun createTransformationPrompt( - problemStatement: String, - renameMap: Map + block: TextBlock, + renameMap: Map, ): Prompt { return Prompt.build("metamorphic-text-transformer") { system { text(""" - You are a technical documentation assistant helping to update code descriptions - after refactoring transformations have been applied. + You are a technical documentation assistant helping to update code + descriptions after refactoring transformations have been applied. You will be given: - 1. An original problem statement - 2. A mapping of old class/method/variable names AND old file paths to their new - names or new locations + 1. A title and a body of a single document block. The body may be + multiline markdown. + 2. A mapping of old class/method/variable names AND old file paths + to their new names or new locations. The mapping may contain two kinds of entries: - - Identifier renames: the value is a Java identifier (e.g. `computeTotal`). - Replace every occurrence of the old simple name in the problem statement with - the new one wherever it appears (class names, method calls, variable mentions, - fully-qualified references, etc.). - - File / package moves: the value looks like a filesystem path or directory - (contains `/` or `\`). The corresponding source file was relocated, which - typically changes its Java package. Update any fully-qualified class - references, `import` statements, or package mentions in the problem statement - to reflect the new package implied by the new directory. + - Identifier renames: the value is a Java identifier (e.g. + `computeTotal`). Replace every occurrence of the old simple name + in the title and body with the new one wherever it appears (class + names, method calls, variable mentions, fully-qualified + references, etc.). + - File / package moves: the value looks like a filesystem path or + directory (contains `/` or `\`). The corresponding source file + was relocated, which typically changes its Java package. Update + any fully-qualified class references, `import` statements, or + package mentions in the title or body to reflect the new package + implied by the new directory. Your task: - - Update the problem statement to use the NEW names and NEW packages - - Keep the meaning and structure exactly the same - - Preserve all formatting, punctuation, and sentence structure - - If a name doesn't appear in the mapping, leave it unchanged + - Update the title AND the body to use the NEW names and NEW + packages. + - Apply the SAME rename decisions to title and body (they describe + the same change). + - Keep the meaning and structure exactly the same. + - Preserve all formatting, punctuation, and sentence structure. + - If a name doesn't appear in the mapping, leave it unchanged. Important: - - Do NOT add new information - - Do NOT remove information - - Do NOT rephrase or rewrite the content - - ONLY update the names and packages indicated by the rename mapping + - Do NOT add new information. + - Do NOT remove information. + - Do NOT rephrase or rewrite the content. + - ONLY update the names and packages indicated by the rename + mapping. + + Output: a JSON object with two fields, `title` and `body`, holding + the updated values: + ```json + { "title": "...", "body": "..." } + ``` """.trimIndent()) } user { - text("## Original Problem Statement\n") - text(problemStatement) + text("## Original Title:") + newline() + text("'''") + newline() + text(block.title) + newline() + text("'''") + text("\n\n") + + text("## Original Body:") + newline() + text("'''") + newline() + text(block.body) + newline() + text("'''") text("\n\n") val (moves, renames) = renameMap.entries.partition { (_, value) -> @@ -145,61 +162,8 @@ class MetamorphicTextTransformer( } text("\n") - text("Now, update the problem statement with the new names and packages.") + text("Now, update both the title and the body with the new names and packages.") } } } } - -/** - * CLI entry point for transforming texts from command line. - * - * Usage: - * ./gradlew transformMetamorphicTexts \ - * -PproblemStatement="..." \ - * -PmemoryFile="/path/to/memory.json" \ - * -PoutputFile="/path/to/output.json" - */ -suspend fun main(args: Array) { - if (args.size != 3) { - println("Usage: transformMetamorphicTexts ") - return - } - - val problemStatement = args[0] - val memoryFile = args[1] - val outputFile = args[2] - - try { - // Initialize LLM (using GPT-4 via Grazie) - val token = System.getenv("GRAZIE_TOKEN") ?: run { - println("ERROR: GRAZIE_TOKEN environment variable not set") - return - } - - val llm = LLM.fromGrazie( - model = OpenAIModels.Chat.GPT5Mini, - token = token - ) - - val transformer = MetamorphicTextTransformer(llm) - - println("Transforming texts using memory file: $memoryFile") - val result = transformer.transformTexts( - problemStatement = problemStatement, - memoryFilePath = memoryFile - ) - - if (result != null) { - // Write result to JSON file - val json = Json.encodeToString(result) - File(outputFile).writeText(json) - println("SUCCESS: Transformed texts written to: $outputFile") - } else { - println("ERROR: Failed to transform texts") - } - } catch (e: Exception) { - println("ERROR: ${e.message}") - e.printStackTrace() - } -} diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/ParaphraseTextTransformer.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/ParaphraseTextTransformer.kt index 6e26f8a..ccd8b6f 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/ParaphraseTextTransformer.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/ParaphraseTextTransformer.kt @@ -7,8 +7,10 @@ import com.intellij.openapi.diagnostic.thisLogger import kotlinx.serialization.Serializable /** - * Rewrites a problem statement to look surface-different while preserving exact semantics. - * Used in the eval pipeline AFTER [MetamorphicTextTransformer] has synced renames/moves. + * Rewrites a [TextBlock] (`{title, body}` pair) so it looks surface-different while + * preserving exact semantics. One LLM call per block so the title and body stay + * coherent (same voice, same synonym choices). Used in the eval pipeline AFTER + * [MetamorphicTextTransformer] has synced renames/moves. */ class ParaphraseTextTransformer( private val llm: LLM, @@ -16,49 +18,54 @@ class ParaphraseTextTransformer( private val logger = thisLogger().withStdout() @Serializable - data class ParaphrasedText( - val problemStatement: String, + private data class ParaphrasedBlock( + val title: String, + val body: String, ) /** - * Asks the LLM to paraphrase [problemStatement]. Returns null if the LLM call fails. + * Asks the LLM to paraphrase [block]'s title and body together. Returns [block] + * verbatim when both fields are blank. Returns null on LLM failure. */ - suspend fun rewrite(problemStatement: String): ParaphrasedText? { - if (problemStatement.isBlank()) { - logger.warn("WARNING: Empty problem statement passed to paraphraser") - return ParaphrasedText(problemStatement) - } + suspend fun rewriteBlock(block: TextBlock): TextBlock? { + if (block.title.isBlank() && block.body.isBlank()) return block - val prompt = createRewritePrompt(problemStatement) + val prompt = createRewritePrompt(block) logger.info("Created paraphrase prompt:\n'''$prompt\n'''") - return llm.structuredRequest( + val result = llm.structuredRequest( prompt = prompt, maxRetries = 3, maxFixingAttempts = 2, - ) + ) ?: return null + + return TextBlock(title = result.title, body = result.body) } - private fun createRewritePrompt(problemStatement: String): Prompt { - return Prompt.build("paraphrase-problem-statement") { + private fun createRewritePrompt(block: TextBlock): Prompt { + return Prompt.build("paraphrase-text-block") { system { text(""" You are a technical-documentation paraphrasing assistant. - You will be given a problem statement. Your task is to AGGRESSIVELY - rewrite it so the result looks SUBSTANTIALLY different on the surface - while remaining a strict semantic synonym — every requirement, fact, - and constraint preserved exactly. + You will be given a block of documentation: a TITLE and a BODY that + describe the same change. Your task is to AGGRESSIVELY rewrite both + so the result looks SUBSTANTIALLY different on the surface while + remaining a strict semantic synonym — every requirement, fact, and + constraint preserved exactly. Apply the SAME rewriting voice and + synonym choices to title and body so they remain consistent with + each other. - Be bold with the rewrite. A near-copy of the input is a FAILURE. Aim - for a high-effort rewrite that a reader would not recognise as the - same prose at first glance, yet a domain expert would confirm carries - the same intent. + Be bold with the rewrite. A near-copy of the input is a FAILURE. + Aim for a high-effort rewrite that a reader would not recognise as + the same prose at first glance, yet a domain expert would confirm + carries the same intent. HARD CONSTRAINTS — preserve verbatim, never alter: - - All identifiers: class names, method names, variable names, package - names, file paths, command-line flags, environment variables, URLs, - version strings. Do NOT rename, translate, or pluralise them. + - All identifiers: class names, method names, variable names, + package names, file paths, command-line flags, environment + variables, URLs, version strings. Do NOT rename, translate, or + pluralise them. - All code-like tokens inside `backticks` and inside fenced code blocks (```...```). Do not edit, reorder, or reformat code fences or their contents. @@ -66,31 +73,31 @@ class ParaphraseTextTransformer( "200 OK", "UTF-8"). - Markdown structural elements: headings, bullet lists, numbered lists, and tables. Their COUNT and the order of their items must - stay the same; you may rephrase the prose inside each item, but do - not add, drop, merge, or split items, and do not change heading - levels. + stay the same; you may rephrase the prose inside each item, but + do not add, drop, merge, or split items, and do not change + heading levels. REQUIRED SURFACE CHANGES — apply several of these, not just one: - 1. Lexical: replace ordinary verbs, nouns, adjectives, and connectors - with synonyms or near-synonyms ("provides" → "exposes", - "responsible for" → "in charge of", "must continue to" → "are - still required to"). Do this for the MAJORITY of non-identifier - content words. + 1. Lexical: replace ordinary verbs, nouns, adjectives, and + connectors with synonyms or near-synonyms ("provides" → + "exposes", "responsible for" → "in charge of", "must continue + to" → "are still required to"). Do this for the MAJORITY of + non-identifier content words. 2. Syntactic: reshape sentences. Convert active ↔ passive voice, swap subject/object framing, hoist subordinate clauses to the - front, turn "X does Y so that Z" into "Z requires that X does Y", - and similar. At least half of the sentences should differ in - structure from their original counterparts. - 3. Granularity: split long sentences into shorter ones, or fuse two - short sentences into one — wherever it improves rhythm and the - meaning is preserved. + front, turn "X does Y so that Z" into "Z requires that X does + Y", and similar. At least half of the sentences should differ + in structure from their original counterparts. + 3. Granularity: split long sentences into shorter ones, or fuse + two short sentences into one — wherever it improves rhythm and + the meaning is preserved. 4. Sentence ordering WITHIN A PARAGRAPH: you may reorder sentences within the same paragraph if it preserves logical flow. Do NOT move sentences across paragraph boundaries or across markdown sections. - 5. Register tightening: keep tone neutral and technical; trim throat- - clearing phrases ("In order to" → "To") where it does not change - meaning. + 5. Register tightening: keep tone neutral and technical; trim + throat-clearing phrases ("In order to" → "To") where it does + not change meaning. FORBIDDEN: - Do NOT add facts, examples, qualifications, or reasoning the @@ -104,36 +111,44 @@ class ParaphraseTextTransformer( narration. SELF-CHECK before responding: - - Could a reader infer any requirement that was not in the original? + - Could a reader infer any requirement that was not in the + original? If yes, revise. + - Could a reader miss any requirement that was in the original? If yes, revise. - - Could a reader miss any requirement that was in the original? If - yes, revise. - Does at least 60% of the prose read differently (different word choice or sentence shape) from the input? If no, rewrite more aggressively. + - Does the rewritten title use the same voice / synonym choices as + the rewritten body? If no, align them. - Output: a JSON object with a single field `problemStatement` holding - the rewritten text: + Output: a JSON object with two fields, `title` and `body`, holding + the rewritten values: ```json - { - "problemStatement": "..." - } + { "title": "...", "body": "..." } ``` """.trimIndent()) } user { - text("## Original Problem Statement:") - + text("## Original Title:") newline() text("'''") newline() - text(problemStatement) + text(block.title) newline() text("'''") + text("\n\n") + text("## Original Body:") + newline() + text("'''") + newline() + text(block.body) + newline() + text("'''") text("\n\n") - text("Now produce the paraphrased problem statement following the given rules.") + + text("Now produce the paraphrased title and body following the given rules.") } } } diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TextBlock.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TextBlock.kt new file mode 100644 index 0000000..05161a2 --- /dev/null +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TextBlock.kt @@ -0,0 +1,12 @@ +package com.github.pderakhshanfar.codecocoonplugin.services + +/** + * A pair of related prose fields that should be transformed together by a single LLM + * call so the result is internally coherent (consistent voice, consistent identifier + * rewrites). Used for the main `{title, body}` of a benchmark record and for each + * `resolved_issues[i].{title, body}` pair. + */ +data class TextBlock( + val title: String, + val body: String, +) From 227859dde7f7c2c6d2b6d6f97fb59e3633fa6ccf Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sun, 26 Apr 2026 20:45:35 +0200 Subject: [PATCH 27/67] fix: ensure all documents are committed and saved to disk to avoid non-deterministic behavior during project close and renaming operations --- .../appstarter/HeadlessModeStarter.kt | 11 +++++++++++ .../renaming/RenameClassTransformation.kt | 7 +++++++ .../renaming/RenameMethodTransformation.kt | 7 +++++++ .../renaming/RenameVariableTransformation.kt | 7 +++++++ 4 files changed, 32 insertions(+) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt index 88230bb..fc907c2 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt @@ -19,11 +19,13 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ApplicationStarter import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.util.Disposer import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VfsUtil +import com.intellij.psi.PsiDocumentManager import com.intellij.psi.codeStyle.JavaCodeStyleSettings import kotlinx.coroutines.runBlocking import java.io.File @@ -88,6 +90,15 @@ class HeadlessModeStarter : ApplicationStarter { // close project and exit logger.info("[CodeCocoon Starter] Execution completed") + // Final flush before close: commit any pending PSI changes and write + // documents to disk explicitly, so close-time hooks don't get a chance + // to introduce non-deterministic edits (e.g. import re-ordering whose + // outcome depends on accumulated unflushed state across rename calls). + ApplicationManager.getApplication().invokeAndWait { + PsiDocumentManager.getInstance(project).commitAllDocuments() + FileDocumentManager.getInstance().saveAllDocuments() + } + ApplicationManager.getApplication().invokeAndWait { ProjectManager.getInstance().closeAndDispose(project) logger.info("[CodeCocoon Starter] Project is closed successfully") diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt index bf40557..b8bb69a 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt @@ -17,6 +17,7 @@ import com.github.pderakhshanfar.codecocoonplugin.transformation.requireOrDefaul import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.readAction import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex @@ -300,6 +301,12 @@ class RenameClassTransformation( ApplicationManager.getApplication().invokeAndWait { PsiDocumentManager.getInstance(project).commitAllDocuments() renameProcessor.run() + // Lock in PSI/document/disk state immediately so subsequent renames + // (and the final project close) don't trigger close-time hooks whose + // behaviour depends on accumulated unflushed state — that previously + // produced non-deterministic import positions across morph runs. + PsiDocumentManager.getInstance(project).commitAllDocuments() + FileDocumentManager.getInstance().saveAllDocuments() } val modifiedFiles = withReadAction { diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 1ebfe84..a62b331 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -18,6 +18,7 @@ import com.github.pderakhshanfar.codecocoonplugin.transformation.requireOrDefaul import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.readAction import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex @@ -393,6 +394,12 @@ class RenameMethodTransformation( ApplicationManager.getApplication().invokeAndWait { PsiDocumentManager.getInstance(project).commitAllDocuments() renameProcessor.run() + // Lock in PSI/document/disk state immediately so subsequent renames + // (and the final project close) don't trigger close-time hooks whose + // behaviour depends on accumulated unflushed state — that previously + // produced non-deterministic import positions across morph runs. + PsiDocumentManager.getInstance(project).commitAllDocuments() + FileDocumentManager.getInstance().saveAllDocuments() } val modifiedFiles = IntelliJAwareTransformation.withReadAction { diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt index 56118c9..b8f9274 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt @@ -16,6 +16,7 @@ import com.github.pderakhshanfar.codecocoonplugin.transformation.requireOrDefaul import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.readAction import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex @@ -345,6 +346,12 @@ class RenameVariableTransformation( ApplicationManager.getApplication().invokeAndWait { PsiDocumentManager.getInstance(project).commitAllDocuments() renameProcessor.run() + // Lock in PSI/document/disk state immediately so subsequent renames + // (and the final project close) don't trigger close-time hooks whose + // behaviour depends on accumulated unflushed state — that previously + // produced non-deterministic import positions across morph runs. + PsiDocumentManager.getInstance(project).commitAllDocuments() + FileDocumentManager.getInstance().saveAllDocuments() } val modifiedFiles = withReadAction { From c03351a82c090a432c6096845b99f3513b05845c Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sun, 26 Apr 2026 20:52:01 +0200 Subject: [PATCH 28/67] fix: ensure all documents are committed and saved after file moves to prevent inconsistent state during subsequent operations --- .../MoveFileIntoSuggestedDirectoryTransformation.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt index 5cdfd36..93c0738 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt @@ -16,6 +16,7 @@ import com.github.pderakhshanfar.codecocoonplugin.transformation.requireOrDefaul import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex @@ -237,6 +238,14 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( ApplicationManager.getApplication().invokeAndWait { PsiDocumentManager.getInstance(project).commitAllDocuments() processor.run() + // Lock in PSI/document/disk state immediately so subsequent + // transformations (and the final project close) don't trigger + // close-time hooks whose behaviour depends on accumulated + // unflushed state — a move propagates package declarations and + // import updates across every referencing file, the same cascade + // pattern as a rename. + PsiDocumentManager.getInstance(project).commitAllDocuments() + FileDocumentManager.getInstance().saveAllDocuments() } } catch (err: ProcessCanceledException) { // NOTE: `ProcessCanceledException` cannot be silenced, see its Javadoc From 69252d76072c975f919f5c5482eefd54278c764f Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sun, 26 Apr 2026 21:51:17 +0200 Subject: [PATCH 29/67] fix: ensure modified files are snapshotted before renaming to prevent inconsistent behavior across transformations --- .../renaming/RenameClassTransformation.kt | 22 +++-- .../renaming/RenameMethodTransformation.kt | 88 ++++++++++++------- .../renaming/RenameVariableTransformation.kt | 23 +++-- 3 files changed, 84 insertions(+), 49 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt index b8bb69a..b2b796c 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt @@ -298,6 +298,19 @@ class RenameClassTransformation( ) } + // Snapshot modified files BEFORE run(): findUsages() must run on + // the pre-rename PSI to return the references that will actually + // be rewritten. After run() the seed element has been renamed and + // the result is unreliable. + val modifiedFiles = withReadAction { + val files = mutableSetOf() + renameProcessor.findUsages().forEach { usageInfo -> + usageInfo.file?.let { files.add(it) } + } + psiClass.containingFile?.let { files.add(it) } + files + } + ApplicationManager.getApplication().invokeAndWait { PsiDocumentManager.getInstance(project).commitAllDocuments() renameProcessor.run() @@ -308,15 +321,6 @@ class RenameClassTransformation( PsiDocumentManager.getInstance(project).commitAllDocuments() FileDocumentManager.getInstance().saveAllDocuments() } - - val modifiedFiles = withReadAction { - val files = mutableSetOf() - renameProcessor.findUsages().forEach { usageInfo -> - usageInfo.file?.let { files.add(it) } - } - psiClass.containingFile?.let { files.add(it) } - files - } logger.info(" • Renamed `$oldName` to `$newName`") modifiedFiles } catch (e: ProcessCanceledException) { diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index a62b331..e4fb221 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -140,7 +140,9 @@ class RenameMethodTransformation( emptyMap() } - // Try each suggestion until one succeeds for ALL methods in the family + // Try each suggestion until one succeeds for the whole family. + // The family is renamed atomically via a single RenameProcessor — + // see tryRenameMethodFamily for why per-method iteration is unsafe. var familyRenamed = false for (suggestion in suggestions) { // Skip if suggestion is the same as the original name (no-op rename) @@ -148,15 +150,14 @@ class RenameMethodTransformation( continue } - // Attempt to rename all methods in the family to the same name logger.info(" • ${familyIndex + 1}) Renaming `$familyName` overload family to `$suggestion` (${family.methods.size} overloads):") - val allSucceeded = family.methods.all { method -> - val files = tryRenameMethodAndUsages(psiFile.project, method, suggestion, searchInComments) - if (files != null) { - modifiedFiles.addAll(files) + val files = tryRenameMethodFamily(psiFile.project, family.methods, suggestion, searchInComments) + if (files != null) { + modifiedFiles.addAll(files) - // Store in memory if needed (using pre-generated signature) - if (saveRenamesInMemory) { + // Store all family signatures in memory under the same suggestion + if (saveRenamesInMemory) { + for (method in family.methods) { val signature = methodSignatures[method] if (signature != null) { memory?.put(signature, suggestion) @@ -165,13 +166,8 @@ class RenameMethodTransformation( logger.warn(" ⊘ Could not generate signature for method before renaming") } } - true - } else { - false } - } - if (allSucceeded) { renamedMethodCount += family.methods.size familyRenamed = true break @@ -373,22 +369,58 @@ class RenameMethodTransformation( return if (normalized.contains(internalFallback)) normalized else normalized + internalFallback } - private fun tryRenameMethodAndUsages( + /** + * Renames an entire overload family atomically by registering all family + * members on a single [RenameProcessor] (seed + `addElement`) and running + * once. This way IntelliJ resolves overload-bound call-sites against the + * complete family before rewriting, so varargs / multi-arg call sites + * referencing any overload are rewritten consistently. + * + * The previous per-method approach left stray call sites untouched + * because the resolver could rebind to another overload mid-loop. + * + * Returns the set of modified files (snapshotted from `findUsages()` + * BEFORE `run()`, so the seed PSI element is still in its original + * state), or null on failure. + */ + private fun tryRenameMethodFamily( project: Project, - method: PsiMethod, + methods: List, newName: String, searchInComments: Boolean, ): MutableSet? { + if (methods.isEmpty()) return null return try { - val oldName = method.name + val firstMethod = methods.first() + val oldName = IntelliJAwareTransformation.withReadAction { firstMethod.name } + val renameProcessor = IntelliJAwareTransformation.withReadAction { - RenameProcessor( + val processor = RenameProcessor( /* project = */ project, - /* element = */ method, + /* element = */ firstMethod, /* newName = */ newName, - /* isSearchInComments= */ searchInComments, - /* isSearchTextOccurrences = */ false + /* isSearchInComments = */ searchInComments, + /* isSearchTextOccurrences = */ false, ) + for (extra in methods.drop(1)) { + processor.addElement(extra, newName) + } + processor + } + + // Snapshot modified files BEFORE run(): findUsages() must run on + // the pre-rename PSI to return the references that will actually + // be rewritten. After run() the seed element has been renamed and + // the result is unreliable. + val modifiedFiles = IntelliJAwareTransformation.withReadAction { + val files = mutableSetOf() + renameProcessor.findUsages().forEach { usageInfo -> + usageInfo.file?.let { files.add(it) } + } + for (method in methods) { + method.containingFile?.let { files.add(it) } + } + files } ApplicationManager.getApplication().invokeAndWait { @@ -402,23 +434,17 @@ class RenameMethodTransformation( FileDocumentManager.getInstance().saveAllDocuments() } - val modifiedFiles = IntelliJAwareTransformation.withReadAction { - val files = mutableSetOf() - renameProcessor.findUsages().forEach { usageInfo -> - usageInfo.file?.let { files.add(it) } - } - method.containingFile?.let { files.add(it) } - files - } - logger.info(" • Renamed `$oldName` to `$newName` in ${modifiedFiles.size} files") + val overloadLabel = if (methods.size > 1) "${methods.size} overloads" else "1 overload" + logger.info(" • Renamed `$oldName` ($overloadLabel) to `$newName` in ${modifiedFiles.size} files") modifiedFiles } catch (e: ProcessCanceledException) { // Must rethrow control flow exceptions - logger.warn("Rename method and usage cancelled: ${e.message}") + logger.warn("Rename method family cancelled: ${e.message}") throw e } catch (e: Exception) { // Rename failed (conflicts, PSI errors, etc.) - return null to try the next suggestion - logger.info(" ⊘ Skipped ${method.name}:\n (Reason: ${e.message})") + val familyName = methods.firstOrNull()?.name ?: "" + logger.info(" ⊘ Skipped family `$familyName`:\n (Reason: ${e.message})") null } } diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt index b8f9274..9687530 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt @@ -343,6 +343,20 @@ class RenameVariableTransformation( ) } + // Snapshot modified files BEFORE run(): findUsages() must run on + // the pre-rename PSI to return the references that will actually + // be rewritten. After run() the seed element has been renamed and + // the result is unreliable — that previously caused the Javadoc + // `@param` tag to be rewritten in some morph runs but not others. + val modifiedFiles = withReadAction { + val files = mutableSetOf() + renameProcessor.findUsages().forEach { usageInfo -> + usageInfo.file?.let { files.add(it) } + } + psiVariable.containingFile?.let { files.add(it) } + files + } + ApplicationManager.getApplication().invokeAndWait { PsiDocumentManager.getInstance(project).commitAllDocuments() renameProcessor.run() @@ -354,15 +368,6 @@ class RenameVariableTransformation( FileDocumentManager.getInstance().saveAllDocuments() } - val modifiedFiles = withReadAction { - val files = mutableSetOf() - renameProcessor.findUsages().forEach { usageInfo -> - usageInfo.file?.let { files.add(it) } - } - psiVariable.containingFile?.let { files.add(it) } - files - } - val fileCountString = if (modifiedFiles.size > 1) " in ${modifiedFiles.size} files" else "" logger.info(" • Renamed `$oldName` to `$newName`$fileCountString") modifiedFiles From f13e69d9c3badc8d1514adb492ccd2ca6921892d Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sun, 26 Apr 2026 23:18:41 +0200 Subject: [PATCH 30/67] fix: improve override detection logic in `RenameMethodTransformation` Changes: - Skip override check for static methods to avoid false positives caused by unrelated name matches. - Ensure instance methods are genuine overrides by verifying super-methods are within the declared inheritance chain. --- .../renaming/RenameMethodTransformation.kt | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index e4fb221..30ef5b1 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -678,15 +678,47 @@ class RenameMethodTransformation( // Step 1: Collect all methods without filtering val allMethods = collectAllMethods(psiFile) - // Step 2: Filter out override methods BEFORE grouping - // Override methods must keep their original names to maintain inheritance contracts + // Step 2: Filter out genuine overrides BEFORE grouping. + // Override methods must keep their original names to maintain inheritance contracts. + // + // Two guards beyond the obvious findSuperMethods() check: + // 1. Skip the check entirely for `static` methods — Java statics are + // not inherited, so findSuperMethods() for a static is a category + // error. The PSI implementation can return false positives when + // an unrelated type elsewhere in the project declares a method + // with the same name + erased parameter list (observed: fastjson + // v1-compat `com.alibaba.fastjson.JSON.toJSONString(Object)` + // reported as a super of the v2 static interface method, causing + // that overload to be silently dropped from the family). + // 2. For instance methods, require the matched super-method's + // containing class to be in the declared extends/implements chain + // of the owning class — not just any project-wide name match. val nonOverrideMethods = allMethods.filter { method -> - val superMethods = method.findSuperMethods() - if (superMethods.isNotEmpty()) { + val isStatic = IntelliJAwareTransformation.withReadAction { + method.hasModifierProperty(PsiModifier.STATIC) + } + if (isStatic) { + return@filter true + } + val superMethods = IntelliJAwareTransformation.withReadAction { method.findSuperMethods() } + if (superMethods.isEmpty()) { + return@filter true + } + val ownerSupers = IntelliJAwareTransformation.withReadAction { + method.containingClass?.supers?.mapNotNull { it.qualifiedName }?.toSet().orEmpty() + } + val genuineOverride = IntelliJAwareTransformation.withReadAction { + superMethods.any { sm -> sm.containingClass?.qualifiedName in ownerSupers } + } + if (genuineOverride) { val signature = IntelliJAwareTransformation.withReadAction { PsiSignatureGenerator.generateSignature(method) } - logger.info(" ⊘ Method `${method.name}` ($signature) - skipped (overrides super method from `${superMethods.firstOrNull()?.containingClass?.qualifiedName}`)") + val ownerFqn = IntelliJAwareTransformation.withReadAction { + superMethods.firstOrNull { sm -> sm.containingClass?.qualifiedName in ownerSupers } + ?.containingClass?.qualifiedName + } + logger.info(" ⊘ Method `${method.name}` ($signature) - skipped (overrides super method from `$ownerFqn`)") false } else { true From 0078695a869b1538032d2712ac4e3ed7d620dd63 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Mon, 27 Apr 2026 20:15:40 +0200 Subject: [PATCH 31/67] fix!: remove blocking `withReadAction` calls in `RenameMethodTransformation` --- .../renaming/RenameMethodTransformation.kt | 35 +++++++------------ 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 30ef5b1..4a9cba8 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -8,7 +8,6 @@ import com.github.pderakhshanfar.codecocoonplugin.components.transformations.Int import com.github.pderakhshanfar.codecocoonplugin.components.transformations.SelfManagedTransformation import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout -import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.allowedAnnotationsOnly import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.document import com.github.pderakhshanfar.codecocoonplugin.intellij.vfs.relativeToRootOrAbsPath import com.github.pderakhshanfar.codecocoonplugin.java.JavaTransformation @@ -693,31 +692,25 @@ class RenameMethodTransformation( // 2. For instance methods, require the matched super-method's // containing class to be in the declared extends/implements chain // of the owning class — not just any project-wide name match. + // Already inside an outer `withReadAction { findAllValidMethodFamilies(...) }` + // at the call site — nesting `IntelliJAwareTransformation.withReadAction` here + // would re-enter `runBlocking { readAction { } }` on the same thread that already + // holds a non-blocking read lock and deadlock against any queued write action. val nonOverrideMethods = allMethods.filter { method -> - val isStatic = IntelliJAwareTransformation.withReadAction { - method.hasModifierProperty(PsiModifier.STATIC) - } + val isStatic = method.hasModifierProperty(PsiModifier.STATIC) if (isStatic) { return@filter true } - val superMethods = IntelliJAwareTransformation.withReadAction { method.findSuperMethods() } + val superMethods = method.findSuperMethods() if (superMethods.isEmpty()) { return@filter true } - val ownerSupers = IntelliJAwareTransformation.withReadAction { - method.containingClass?.supers?.mapNotNull { it.qualifiedName }?.toSet().orEmpty() - } - val genuineOverride = IntelliJAwareTransformation.withReadAction { - superMethods.any { sm -> sm.containingClass?.qualifiedName in ownerSupers } - } + val ownerSupers = method.containingClass?.supers?.mapNotNull { it.qualifiedName }?.toSet().orEmpty() + val genuineOverride = superMethods.any { sm -> sm.containingClass?.qualifiedName in ownerSupers } if (genuineOverride) { - val signature = IntelliJAwareTransformation.withReadAction { - PsiSignatureGenerator.generateSignature(method) - } - val ownerFqn = IntelliJAwareTransformation.withReadAction { - superMethods.firstOrNull { sm -> sm.containingClass?.qualifiedName in ownerSupers } - ?.containingClass?.qualifiedName - } + val signature = PsiSignatureGenerator.generateSignature(method) + val ownerFqn = superMethods.firstOrNull { sm -> sm.containingClass?.qualifiedName in ownerSupers } + ?.containingClass?.qualifiedName logger.info(" ⊘ Method `${method.name}` ($signature) - skipped (overrides super method from `$ownerFqn`)") false } else { @@ -756,10 +749,8 @@ class RenameMethodTransformation( } logger.info(" • ${family.methodName} [$modifier, ${family.methods.size} overload(s)]:") - val signatures = IntelliJAwareTransformation.withReadAction { - family.methods.mapNotNull { method -> - PsiSignatureGenerator.generateSignature(method) - } + val signatures = family.methods.mapNotNull { method -> + PsiSignatureGenerator.generateSignature(method) } val displayLimit = 10 From 1c26f446f438a2fb1e275ce484c0eb385d9f38bf Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Mon, 27 Apr 2026 20:15:55 +0200 Subject: [PATCH 32/67] fix: commit and save all documents after transformations to ensure consistent state and avoid race conditions --- .../services/TransformationService.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt index 6da0c8a..6980004 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt @@ -11,15 +11,18 @@ import com.github.pderakhshanfar.codecocoonplugin.intellij.vfs.findVirtualFile import com.github.pderakhshanfar.codecocoonplugin.intellij.vfs.relativeToRootOrAbsPath import com.github.pderakhshanfar.codecocoonplugin.memory.PersistentMemory import com.github.pderakhshanfar.codecocoonplugin.transformation.Transformation +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.smartReadAction import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.project.Project import com.intellij.openapi.project.guessProjectDir import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileVisitor +import com.intellij.psi.PsiDocumentManager /** * Application-level service responsible for managing metamorphic transformations @@ -198,6 +201,19 @@ class TransformationService { } } } + + // Flush PSI changes to disk once per file context (cheaper than flushing after every rename). + ApplicationManager.getApplication().invokeAndWait { + val commitStart = System.currentTimeMillis() + logger.info("[TransformationService] Committing all documents for '$filepath'...") + PsiDocumentManager.getInstance(project).commitAllDocuments() + logger.info("[TransformationService] Committed all documents in ${System.currentTimeMillis() - commitStart}ms") + + val saveStart = System.currentTimeMillis() + logger.info("[TransformationService] Saving all documents for '$filepath'...") + FileDocumentManager.getInstance().saveAllDocuments() + logger.info("[TransformationService] Saved all documents in ${System.currentTimeMillis() - saveStart}ms") + } } logger.info("[TransformationService] Transformation summary: $successCount succeeded, $failureCount failed, $skippedCount skipped") From 8aecff5bd827f8c740ec83c6bfffea7143174d0c Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Wed, 29 Apr 2026 02:42:46 +0200 Subject: [PATCH 33/67] fix: skip no-op file move suggestions in `MoveFileIntoSuggestedDirectoryTransformation` --- .../suggestions/impl/SuggestNewDirectory.kt | 32 +++++++++++-------- ...ileIntoSuggestedDirectoryTransformation.kt | 11 +++++++ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/SuggestNewDirectory.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/SuggestNewDirectory.kt index 6602609..6d4f44e 100644 --- a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/SuggestNewDirectory.kt +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/SuggestNewDirectory.kt @@ -153,7 +153,7 @@ private fun buildSystemPrompt(projectRoot: String, existingOnly: Boolean): Strin CRITICAL RULES: - [!] OUTPUT ABSOLUTE PATHS BASED ON THE PROJECT ROOT! - - [!] DO NOT OUTPUT THE SAME DIRECTORY WHERE THE GIVEN FILE ALREADY RESIDES! + - [!] DO NOT SUGGEST THE `CURRENT DIRECTORY` LISTED IN THE USER PROMPT — that produces a no-op move and the suggestion will be discarded. - Never suggest locations that would cause naming conflicts - Prefer existing directories unless the class clearly belongs to a new feature module - Consider import dependencies visible in the source file @@ -177,19 +177,23 @@ private fun buildSystemPrompt(projectRoot: String, existingOnly: Boolean): Strin return basePrompt + "\n\n" + directoryConstraint } -private fun buildUserPrompt(filepath: String, content: String): String = """ - Analyze this Java file and suggest appropriate directory locations. - - FILE PATH: $filepath - - FILE CONTENT (may be truncated): - ```java - $content - ``` - - First, use the `list_directory` tool to understand the current project structure. - Then analyze the class and provide directory suggestions. -""".trimIndent() +private fun buildUserPrompt(filepath: String, content: String): String { + val currentDirectory = File(filepath).parent ?: filepath + return """ + Analyze this Java file and suggest appropriate directory locations. + + FILE PATH: $filepath + CURRENT DIRECTORY: $currentDirectory ← DO NOT suggest this exact directory; doing so is a no-op move. + + FILE CONTENT (may be truncated): + ```java + $content + ``` + + First, use the `list_directory` tool to understand the current project structure. + Then analyze the class and provide directory suggestions different from `CURRENT DIRECTORY`. + """.trimIndent() +} // TODO: parse the requested JSON structure into data class, not list of strings private fun parseDirectorySuggestions(llmOutput: String): Result> { diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt index 93c0738..21ec954 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt @@ -196,6 +196,10 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( val projectRoot = project.basePath ?: return TransformationResult.Failure("Project root not found") + val currentParent = withReadAction { + Paths.get(fileToMove.virtualFile.parent.path).normalize().toString() + } + logger.info(" ⏲ Attempting to move $filename into suggestions...") for ((index, suggestionPath) in suggestions.withIndex()) { logger.info(" ↳ Attempting suggestion #${index + 1}: '$suggestionPath'") @@ -211,6 +215,13 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( suggestionPath } + // skip suggestions pointing at the file's current package — would be a no-op move + // and would pollute memory with a self-pointing entry. + if (Paths.get(suggestion).normalize().toString() == currentParent) { + logger.warn(" ⚠ Skipping suggestion #${index + 1}: '$suggestion' equals the current package/directory of '$filename'") + continue + } + val suggestedDirectory = WriteCommandAction.runWriteCommandAction(project) { VfsUtil.createDirectories(suggestion) } From 5669456cedf58075d7f500613e08a0329fc204e7 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Wed, 29 Apr 2026 03:12:46 +0200 Subject: [PATCH 34/67] fix: avoid hanging on failed suggestion in `MoveFileIntoSuggestedDirectoryTransformation` --- ...ileIntoSuggestedDirectoryTransformation.kt | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt index 21ec954..7e9e9f4 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt @@ -27,6 +27,7 @@ import com.intellij.psi.search.searches.ReferencesSearch import com.intellij.refactoring.move.MoveCallback import com.intellij.refactoring.move.moveFilesOrDirectories.MoveFilesOrDirectoriesProcessor import com.intellij.usageView.UsageInfo +import com.intellij.util.containers.MultiMap import kotlinx.coroutines.runBlocking import java.nio.file.Paths import java.util.concurrent.CompletableFuture @@ -111,7 +112,7 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( val suggestedDirectories = result.getOrThrow() logger.info(" • Received ${suggestedDirectories.size} directory suggestions") - return tryToMoveFileIntoSuggestedDirectory( + tryToMoveFileIntoSuggestedDirectory( project = psiFile.project, fileToMove = psiFile, suggestions = suggestedDirectories, @@ -262,9 +263,16 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( // NOTE: `ProcessCanceledException` cannot be silenced, see its Javadoc throw err } catch (err: Exception) { - logger.error("Failed to move '$filename' into suggestion #${index + 1}", err) + logger.error(" ✗ Suggestion #${index + 1} for '$filename' failed: ${err.message}; trying next suggestion", err) + // unblock the join below so the loop can advance to the next suggestion + successfullyMoved.complete(false) } + // If the processor aborted without invoking moveCallback (e.g. showConflicts returned + // false because the move would break package-private references), the future is still + // unset — complete it as `false` so the join doesn't hang. Idempotent on success. + successfullyMoved.complete(false) + // finish when moved successfully into the current suggestion if (successfullyMoved.join()) { val (filesModified, usageSummary) = withReadAction { @@ -412,6 +420,18 @@ private class MoveFilesOrDirectoriesProcessorWrapper( ) { val foundUsages: Map> get() = myFoundUsages + + // BaseRefactoringProcessor.showConflicts throws ConflictsInTestsException in headless/test + // mode. Convert that into a graceful abort: log the conflicts and tell the base processor + // to skip the refactor (return false) — the calling loop then advances to the next suggestion. + override fun showConflicts(conflicts: MultiMap, usages: Array?): Boolean { + if (!conflicts.isEmpty) { + val conflictStr = conflicts.values().joinToString("\n") { " - $it;" } + thisLogger().withStdout().warn(" ⚠ Move blocked by ${conflicts.size()} conflict(s):\n$conflictStr") + return false + } + return super.showConflicts(conflicts, usages) + } } /** From 10a56fa6106a73d68e94fa37c5c383ed4b56877c Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Wed, 29 Apr 2026 03:30:28 +0200 Subject: [PATCH 35/67] fix: remove unconditional `successfullyMoved.complete(false)` --- .../MoveFileIntoSuggestedDirectoryTransformation.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt index 7e9e9f4..08ecab4 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt @@ -268,11 +268,6 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( successfullyMoved.complete(false) } - // If the processor aborted without invoking moveCallback (e.g. showConflicts returned - // false because the move would break package-private references), the future is still - // unset — complete it as `false` so the join doesn't hang. Idempotent on success. - successfullyMoved.complete(false) - // finish when moved successfully into the current suggestion if (successfullyMoved.join()) { val (filesModified, usageSummary) = withReadAction { From 533689072743bd9f4c6a8492b710d734a2f825f3 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 2 May 2026 01:33:58 +0200 Subject: [PATCH 36/67] fix: set `successfullyMoved` to false on found conflicts when tried file move Previously, the transformation hung on `successfullyMoved.join()` because it was only set to true when the move operation succeeded. In cases where the operation fails, `successfullyMoved` never receives a value (neither true, nor false) -> it led to hanging on `join()`. --- ...ileIntoSuggestedDirectoryTransformation.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt index 08ecab4..507444e 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt @@ -240,8 +240,14 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( searchInNonJavaFiles = false, moveCallback = { // overriding `refactoringCompleted` method + logger.info(" • Move processor called `moveCallback`: setting `successfullyMoved=true`") successfullyMoved.complete(true) }, + onConflictsFoundCallback = { + // set `successfullyMoved` to false -> unsuccessful/aborted move operation + logger.warn(" ✗ Move processor called `onConflictsFoundCallback`: setting `successfullyMoved=false`") + successfullyMoved.complete(false) + }, prepareSuccessfulCallback = { /* no-op */ }, ) } @@ -249,7 +255,9 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( try { ApplicationManager.getApplication().invokeAndWait { PsiDocumentManager.getInstance(project).commitAllDocuments() + logger.info(" ↳ Move processor starts running...") processor.run() + logger.info(" • Move processor finished running") // Lock in PSI/document/disk state immediately so subsequent // transformations (and the final project close) don't trigger // close-time hooks whose behaviour depends on accumulated @@ -269,7 +277,10 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( } // finish when moved successfully into the current suggestion - if (successfullyMoved.join()) { + logger.info(" ↳ Awaiting completion of move operation (i.e., `successfullyMoved.join()`)...") + val moveResult = successfullyMoved.join() + logger.info(" ↳ Move operation for suggestion #${index + 1} for '$filename' ${if (moveResult) "succeeded" else "failed"}: '$suggestedDirectory'") + if (moveResult) { val (filesModified, usageSummary) = withReadAction { val usages = processor.foundUsages @@ -395,6 +406,8 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( /** * This wrapper delegates ALL methods to [MoveFilesOrDirectoriesProcessor]. * It only exposes a protected [myFoundUsages] variable for enriched logging. + * + * @param onConflictsFoundCallback called when conflicts list is NOT empty in [showConflicts] method. */ private class MoveFilesOrDirectoriesProcessorWrapper( project: Project, @@ -403,6 +416,7 @@ private class MoveFilesOrDirectoriesProcessorWrapper( searchInComments: Boolean, searchInNonJavaFiles: Boolean, moveCallback: MoveCallback, + private val onConflictsFoundCallback: Runnable, prepareSuccessfulCallback: Runnable ) : MoveFilesOrDirectoriesProcessor( project, @@ -421,8 +435,11 @@ private class MoveFilesOrDirectoriesProcessorWrapper( // to skip the refactor (return false) — the calling loop then advances to the next suggestion. override fun showConflicts(conflicts: MultiMap, usages: Array?): Boolean { if (!conflicts.isEmpty) { + // running callback on failed move operation + onConflictsFoundCallback.run() + val conflictStr = conflicts.values().joinToString("\n") { " - $it;" } - thisLogger().withStdout().warn(" ⚠ Move blocked by ${conflicts.size()} conflict(s):\n$conflictStr") + thisLogger().withStdout().warn(" ⚠ Move blocked by ${conflicts.size()} conflict(s):\n'''\n$conflictStr\n'''") return false } return super.showConflicts(conflicts, usages) From aaa62c640cb685fc002cfdfae25da3fd37d72365 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 2 May 2026 02:19:39 +0200 Subject: [PATCH 37/67] feat: output succeeded/failed/skipped transformation ids --- .../services/TransformationService.kt | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt index 6980004..fe785f3 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt @@ -138,9 +138,10 @@ class TransformationService { PersistentMemory(config.memoryFilepath).use { memory -> logger.info("[TransformationService] Created global memory at '${config.memoryFilepath}'") - var successCount = 0 - var failureCount = 0 - var skippedCount = 0 + val succeededIds = mutableListOf() + val failedIds = mutableListOf() + val skippedIds = mutableListOf() + var fileSkippedCount = 0 // Collect and filter file contexts together with their virtual files logger.info("[TransformationService] Collecting file contexts for ${files.size} files...") @@ -156,14 +157,14 @@ class TransformationService { " ✗ Failed to create file context for file: '$filePath'. Skipping this filepath.", result.exceptionOrNull(), ) - skippedCount++ + fileSkippedCount++ continue } val context = result.getOrThrow() // filter unwanted files if (!fileFilter(context)) { - skippedCount++ + fileSkippedCount++ continue } add(context) @@ -188,15 +189,15 @@ class TransformationService { when (val result = executor.execute(transformation, context, memory)) { is TransformationResult.Success -> { logger.info(" ✓ ${result.message}") - successCount++ + succeededIds += transformation.id } is TransformationResult.Failure -> { logger.error(" ✗ ${result.error}", result.exception) - failureCount++ + failedIds += transformation.id } is TransformationResult.Skipped -> { logger.info(" ⊘ Skipped: ${result.reason}") - skippedCount++ + skippedIds += transformation.id } } } @@ -216,7 +217,16 @@ class TransformationService { } } - logger.info("[TransformationService] Transformation summary: $successCount succeeded, $failureCount failed, $skippedCount skipped") + val header = buildString { + append("[TransformationService] Transformation summary: ") + append("${succeededIds.size} succeeded, ${failedIds.size} failed, ${skippedIds.size} skipped") + if (fileSkippedCount > 0) append(", $fileSkippedCount files skipped") + append(":") + } + logger.info(header) + logger.info("[TransformationService] - succeeded: ${succeededIds.joinToString(", ")}") + logger.info("[TransformationService] - failed: ${failedIds.joinToString(", ")}") + logger.info("[TransformationService] - skipped: ${skippedIds.joinToString(", ")}") } } From 056721eb8e44602e0b2d3c783f3cfbd389a56b96 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 2 May 2026 09:58:03 +0200 Subject: [PATCH 38/67] fix: handle non-physical, compiled, and anonymous classes in method reordering - Skip non-physical, compiled, and anonymous classes to avoid unintended modifications. - Fix method filtering to exclude non-physical and compiled methods. - Pre-validate method copyability to prevent partial class modifications during reordering. - Add error handling for unexpected exceptions to improve reliability. --- .../ReorderClassMethodsTransformation.kt | 121 +++++++++++++----- 1 file changed, 91 insertions(+), 30 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt index 75795e7..36f08e5 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt @@ -13,7 +13,9 @@ import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiAnonymousClass import com.intellij.psi.PsiClass +import com.intellij.psi.PsiCompiledElement import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile @@ -56,37 +58,76 @@ class ReorderClassMethodsTransformation( PsiDocumentManager.getInstance(project).commitAllDocuments() for (psiClass in classes) { - val methods = psiClass.methods.toList() - if (methods.size < 2) { - logger.warn(" ⊘ Class `${psiClass.name}` - has ${methods.size} methods (skipping)") - continue + try { + val allMethods = psiClass.methods.toList() + val methods = allMethods.filter { it.isPhysical && it !is PsiCompiledElement } + val droppedMethods = allMethods - methods.toSet() + if (droppedMethods.isNotEmpty()) { + val droppedNames = droppedMethods.joinToString(", ") { + val reason = when { + it is PsiCompiledElement -> "compiled" + !it.isPhysical -> "non-physical" + else -> "filtered" + } + "${it.name} ($reason)" + } + logger.info( + " ⊘ Class `${psiClass.name}` - dropped ${droppedMethods.size} method(s) " + + "before reorder: $droppedNames" + ) + } + if (methods.size < 2) { + logger.warn(" ⊘ Class `${psiClass.name}` - has ${methods.size} reorderable method(s) (skipping)") + continue + } + + val sortedMethods = reorderMethods(methods) + + if (sortedMethods.map { it.name } == methods.map { it.name }) { + logger.info(" ⊘ Class `${psiClass.name}` - methods already in desired order") + continue + } + + val rBrace = psiClass.rBrace + if (rBrace == null) { + logger.warn(" ⊘ Class `${psiClass.name}` - no closing brace, skipping") + continue + } + + // Pre-validate that every method can be copied. PsiElement.copy() returns null + // for non-copyable elements (synthetic / PsiAugmentProvider-injected light methods, + // etc.), and addBefore(null, ...) would crash with @NotNull violation. Doing this + // before any mutation avoids leaving the class in a half-deleted / half-added state. + val copies = sortedMethods.map { it to it.copy() as? PsiMethod } + val firstNullCopy = copies.firstOrNull { it.second == null } + if (firstNullCopy != null) { + logger.warn( + " ⊘ Class `${psiClass.name}` - method " + + "`${firstNullCopy.first.name}` is non-copyable (likely synthetic / " + + "augmented PSI); skipping class to avoid partial reorder" + ) + continue + } + + // add sorted methods into class + for ((_, copy) in copies) { + psiClass.addBefore(copy!!, rBrace) + } + // remove original methods + for (method in sortedMethods) { + method.delete() + } + + reorderedClassCount += 1 + totalMethodsTouched += methods.size + logger.info(" ✓ Class `${psiClass.name}` - reordered ${methods.size} methods") } - - val sortedMethods = reorderMethods(methods) - - if (sortedMethods.map { it.name } == methods.map { it.name }) { - logger.info(" ⊘ Class `${psiClass.name}` - methods already in desired order") - continue - } - - val rBrace = psiClass.rBrace - if (rBrace == null) { - logger.warn(" ⊘ Class `${psiClass.name}` - no closing brace, skipping") - continue - } - - // add sorted methods into class - for (method in sortedMethods) { - psiClass.addBefore(method.copy(), rBrace) + catch (err: ProcessCanceledException) { + throw err } - // remove original methods - for (method in methods) { - method.delete() + catch (e: Exception) { + logger.error(" ✗ Class `${psiClass.name}` - failed to reorder methods: ${e.message}", e) } - - reorderedClassCount += 1 - totalMethodsTouched += methods.size - logger.info(" ✓ Class `${psiClass.name}` - reordered ${methods.size} methods") } val document = psiFile.document() @@ -128,9 +169,29 @@ class ReorderClassMethodsTransformation( psiFile.accept(object : PsiRecursiveElementVisitor() { override fun visitElement(element: PsiElement) { super.visitElement(element) - if (element is PsiClass) { - classes.add(element) + if (element !is PsiClass) { + return + } + // Skip anonymous classes (visited inside method bodies) — reordering their + // methods is not the user's intent and they often have non-copyable PSI. + if (element is PsiAnonymousClass) { + logger.info(" ⊘ Skipping anonymous class inside ${psiFile.name}") + return + } + if (element.name == null) { + logger.info(" ⊘ Skipping unnamed class-like element in ${psiFile.name}") + return + } + // Skip compiled / non-physical classes (mirror RenameVariableTransformation). + if (element is PsiCompiledElement) { + logger.info(" ⊘ Skipping compiled class `${element.name}` in ${psiFile.name}") + return + } + if (!element.isPhysical) { + logger.info(" ⊘ Skipping non-physical class `${element.name}` in ${psiFile.name}") + return } + classes.add(element) } }) return classes From 351be5bf8d31d502cd0b12c297ed4e49b4028cb3 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 2 May 2026 10:05:33 +0200 Subject: [PATCH 39/67] refactor: don't skip anonymous classes from reordering methods --- .../structural/ReorderClassMethodsTransformation.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt index 36f08e5..77bb024 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt @@ -172,16 +172,17 @@ class ReorderClassMethodsTransformation( if (element !is PsiClass) { return } + // TODO(NOTE): don't skip anonymous classes for eval (possibly, more transformations with anonymous classes included) // Skip anonymous classes (visited inside method bodies) — reordering their // methods is not the user's intent and they often have non-copyable PSI. - if (element is PsiAnonymousClass) { + /*if (element is PsiAnonymousClass) { logger.info(" ⊘ Skipping anonymous class inside ${psiFile.name}") return } if (element.name == null) { logger.info(" ⊘ Skipping unnamed class-like element in ${psiFile.name}") return - } + }*/ // Skip compiled / non-physical classes (mirror RenameVariableTransformation). if (element is PsiCompiledElement) { logger.info(" ⊘ Skipping compiled class `${element.name}` in ${psiFile.name}") From 3b93d35c9c10d2ea68242b721da2b9f2fdbe194f Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sat, 2 May 2026 10:10:02 +0200 Subject: [PATCH 40/67] refactor: use `className` extension to handle anonymous class names --- .../ReorderClassMethodsTransformation.kt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt index 77bb024..77583ba 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/ReorderClassMethodsTransformation.kt @@ -72,25 +72,25 @@ class ReorderClassMethodsTransformation( "${it.name} ($reason)" } logger.info( - " ⊘ Class `${psiClass.name}` - dropped ${droppedMethods.size} method(s) " + + " ⊘ Class `${psiClass.className}` - dropped ${droppedMethods.size} method(s) " + "before reorder: $droppedNames" ) } if (methods.size < 2) { - logger.warn(" ⊘ Class `${psiClass.name}` - has ${methods.size} reorderable method(s) (skipping)") + logger.warn(" ⊘ Class `${psiClass.className}` - has ${methods.size} reorderable method(s) (skipping)") continue } val sortedMethods = reorderMethods(methods) if (sortedMethods.map { it.name } == methods.map { it.name }) { - logger.info(" ⊘ Class `${psiClass.name}` - methods already in desired order") + logger.info(" ⊘ Class `${psiClass.className}` - methods already in desired order") continue } val rBrace = psiClass.rBrace if (rBrace == null) { - logger.warn(" ⊘ Class `${psiClass.name}` - no closing brace, skipping") + logger.warn(" ⊘ Class `${psiClass.className}` - no closing brace, skipping") continue } @@ -102,7 +102,7 @@ class ReorderClassMethodsTransformation( val firstNullCopy = copies.firstOrNull { it.second == null } if (firstNullCopy != null) { logger.warn( - " ⊘ Class `${psiClass.name}` - method " + + " ⊘ Class `${psiClass.className}` - method " + "`${firstNullCopy.first.name}` is non-copyable (likely synthetic / " + "augmented PSI); skipping class to avoid partial reorder" ) @@ -120,13 +120,13 @@ class ReorderClassMethodsTransformation( reorderedClassCount += 1 totalMethodsTouched += methods.size - logger.info(" ✓ Class `${psiClass.name}` - reordered ${methods.size} methods") + logger.info(" ✓ Class `${psiClass.className}` - reordered ${methods.size} methods") } catch (err: ProcessCanceledException) { throw err } catch (e: Exception) { - logger.error(" ✗ Class `${psiClass.name}` - failed to reorder methods: ${e.message}", e) + logger.error(" ✗ Class `${psiClass.className}` - failed to reorder methods: ${e.message}", e) } } @@ -198,6 +198,9 @@ class ReorderClassMethodsTransformation( return classes } + private val PsiClass.className: String + get() = this.name ?: "[anonymous-class]" + companion object { const val ID = "reorder-class-methods-transformation" } From 8227d592b80c0998ffa0387503e4d22b11e0f930 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sun, 3 May 2026 16:29:47 +0200 Subject: [PATCH 41/67] fix: patch method-rename call sites missed by PSI overload resolver In multi-module projects with same-simple-name classes (e.g. fastjson v1-compat `com.alibaba.fastjson.JSON` alongside v2 `com.alibaba.fastjson2.JSON`), MethodReferencesSearch's strict signature search drops call sites whose overload PSI cannot unambiguously bind. RenameProcessor.findUsages() never sees them, so they survive the rename with the old method name and break compilation (e.g. fastjson2 PR-82: `JSONReader.java:922` and `TypeUtils.java:187` left as `JsonMapper.toJSONString(...)` after the family rename). Add a post-rename safety net inside `tryRenameMethodFamily`: walk every Java file in project scope and patch call sites whose `referenceName` matches the old name AND that either resolve into the family or that PSI failed to resolve while their qualifier still points at the family's containing class. Sites PSI resolves to a different method are left alone. Logged via `Post-rename safety net: patched N missed call site(s)` so healthy runs report N=0. --- .../renaming/RenameMethodTransformation.kt | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 4a9cba8..465e183 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -14,8 +14,10 @@ import com.github.pderakhshanfar.codecocoonplugin.java.JavaTransformation import com.github.pderakhshanfar.codecocoonplugin.memory.Memory import com.github.pderakhshanfar.codecocoonplugin.memory.PsiSignatureGenerator import com.github.pderakhshanfar.codecocoonplugin.transformation.requireOrDefault +import com.intellij.ide.highlighter.JavaFileType import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.readAction +import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.progress.ProcessCanceledException @@ -23,6 +25,8 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.* +import com.intellij.psi.search.FileTypeIndex +import com.intellij.psi.search.GlobalSearchScope import com.intellij.psi.search.searches.ReferencesSearch import com.intellij.refactoring.rename.RenameProcessor import kotlinx.coroutines.runBlocking @@ -431,6 +435,20 @@ class RenameMethodTransformation( // produced non-deterministic import positions across morph runs. PsiDocumentManager.getInstance(project).commitAllDocuments() FileDocumentManager.getInstance().saveAllDocuments() + + // Safety net: in multi-module projects with same-simple-name classes + // (e.g. fastjson v1-compat `com.alibaba.fastjson.JSON` alongside v2 + // `com.alibaba.fastjson2.JSON`), MethodReferencesSearch's strict + // signature match can drop call sites whose overload resolution PSI + // can't disambiguate. RenameProcessor.findUsages() then never sees + // them and they survive the rename with the old method name. Walk + // the project once and patch any remaining sites that resolve to + // this family or whose qualifier resolves to its containing class. + val patched = verifyAndPatchMissedCallSites(project, methods, oldName, newName) + if (patched > 0) { + PsiDocumentManager.getInstance(project).commitAllDocuments() + FileDocumentManager.getInstance().saveAllDocuments() + } } val overloadLabel = if (methods.size > 1) "${methods.size} overloads" else "1 overload" @@ -448,6 +466,76 @@ class RenameMethodTransformation( } } + /** + * Post-rename safety net for [tryRenameMethodFamily]. Catches call sites that + * `RenameProcessor.findUsages()` failed to attribute to the family — observed in + * multi-module projects where another class shares the simple name and PSI's + * overload resolver can't unambiguously bind the call to a specific overload + * (e.g. v1-compat `com.alibaba.fastjson.JSON` vs v2 `com.alibaba.fastjson2.JSON`). + * + * Walks every Java file in project scope. Patches a call site only when: + * 1. it resolves to a method that is in the family, OR + * 2. its resolution returned null AND its qualifier resolves to the family's + * containing class — i.e. exactly the broken case we're patching, never + * a call PSI can attribute to a different method. + * + * Must be invoked inside the same `invokeAndWait` envelope as the corresponding + * `RenameProcessor.run()` so PSI/document state is consistent. Returns the + * number of sites rewritten; 0 means PSI search already covered everything. + */ + private fun verifyAndPatchMissedCallSites( + project: Project, + family: List, + oldName: String, + newName: String, + ): Int { + val containingClass = family.firstOrNull()?.containingClass ?: return 0 + val containingFqn = containingClass.qualifiedName ?: return 0 + val familySet = family.toSet() + + val scope = GlobalSearchScope.projectScope(project) + val files = FileTypeIndex.getFiles(JavaFileType.INSTANCE, scope) + val psiManager = PsiManager.getInstance(project) + val factory = JavaPsiFacade.getElementFactory(project) + + val toPatch = mutableListOf() + for (vf in files) { + val psiFile = psiManager.findFile(vf) as? PsiJavaFile ?: continue + psiFile.accept(object : JavaRecursiveElementVisitor() { + override fun visitMethodCallExpression(expr: PsiMethodCallExpression) { + super.visitMethodCallExpression(expr) + val refExpr = expr.methodExpression + if (refExpr.referenceName != oldName) return + + val resolved = expr.resolveMethod() + val resolvesToFamily = resolved != null && resolved in familySet + + val qualifier = refExpr.qualifierExpression as? PsiReferenceExpression + val qualifierClass = qualifier?.resolve() as? PsiClass + val qualifierMatchesContainingClass = + qualifierClass?.qualifiedName == containingFqn + + if (resolvesToFamily || (resolved == null && qualifierMatchesContainingClass)) { + val id = refExpr.referenceNameElement + if (id is PsiIdentifier) toPatch.add(id) + } + } + }) + } + + if (toPatch.isEmpty()) return 0 + + WriteCommandAction.runWriteCommandAction(project) { + for (id in toPatch) { + if (!id.isValid) continue + val newId = factory.createIdentifier(newName) + id.replace(newId) + } + } + logger.info(" ↳ Post-rename safety net: patched ${toPatch.size} missed call site(s) for `$oldName` → `$newName`") + return toPatch.size + } + /** * Checks if annotations pass the configured filter mode (whitelist or blacklist). * From 0145f974523d57123eed7aeded076bb659bfa1b2 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sun, 3 May 2026 17:12:29 +0200 Subject: [PATCH 42/67] feat: pre-expand wildcard imports + enrich safety-net diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1 (diagnostics): the post-rename safety net's reported patch counts were surprisingly large (e.g. 681 for `parseObject`, 529 for `toJSONString`). The numbers are real misses by `MethodReferencesSearch` strict-signature search, not over-matching — but the log made it hard to verify. Now log the count split into `resolved-to-family` vs `qualifier-fallback` buckets and print up to 10 sample `path:line (branch)` sites so the user can spot-check. Issue 2 (wildcard import stripping): IntelliJ's `RenameProcessor` invokes `JavaCodeStyleManager.shortenClassReferences()` on every file whose references it rewrites, which can also strip unrelated `import static X.*;` lines on those files (observed: rename of `JSON` → `JsonCodec` removed `import static junit.framework.TestCase.*;` from test files, breaking `assertNull(...)` and failing test compile). There is no documented IntelliJ toggle (`IDEABKL-3561`) and the existing code-style settings only prevent CREATION of new wildcards. Add `WildcardImportExpander`: a one-shot project-wide pass that runs before any transformation. For each Java file in project scope, replace `import static X.*;` and `import pkg.*;` with explicit single imports for the symbols actually used in the file. Each remaining import then points at a name PSI sees as referenced, so the optimizer cannot drop it. Wired into `TransformationService.applyTransformations` at the top. --- .../renaming/RenameMethodTransformation.kt | 38 +++- .../intellij/psi/WildcardImportExpander.kt | 166 ++++++++++++++++++ .../services/TransformationService.kt | 8 + 3 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/psi/WildcardImportExpander.kt diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 465e183..bd2841c 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -498,9 +498,18 @@ class RenameMethodTransformation( val psiManager = PsiManager.getInstance(project) val factory = JavaPsiFacade.getElementFactory(project) - val toPatch = mutableListOf() + data class PatchSite( + val id: PsiIdentifier, + val path: String, + val line: Int, + val viaResolvedFamily: Boolean, + ) + + val patchSites = mutableListOf() + val docManager = PsiDocumentManager.getInstance(project) for (vf in files) { val psiFile = psiManager.findFile(vf) as? PsiJavaFile ?: continue + val document = docManager.getDocument(psiFile) psiFile.accept(object : JavaRecursiveElementVisitor() { override fun visitMethodCallExpression(expr: PsiMethodCallExpression) { super.visitMethodCallExpression(expr) @@ -516,24 +525,35 @@ class RenameMethodTransformation( qualifierClass?.qualifiedName == containingFqn if (resolvesToFamily || (resolved == null && qualifierMatchesContainingClass)) { - val id = refExpr.referenceNameElement - if (id is PsiIdentifier) toPatch.add(id) + val id = refExpr.referenceNameElement as? PsiIdentifier ?: return + val line = document?.getLineNumber(id.textRange.startOffset)?.plus(1) ?: -1 + patchSites.add(PatchSite(id, vf.path, line, resolvesToFamily)) } } }) } - if (toPatch.isEmpty()) return 0 + if (patchSites.isEmpty()) return 0 WriteCommandAction.runWriteCommandAction(project) { - for (id in toPatch) { - if (!id.isValid) continue + for (p in patchSites) { + if (!p.id.isValid) continue val newId = factory.createIdentifier(newName) - id.replace(newId) + p.id.replace(newId) } } - logger.info(" ↳ Post-rename safety net: patched ${toPatch.size} missed call site(s) for `$oldName` → `$newName`") - return toPatch.size + val resolvedCount = patchSites.count { it.viaResolvedFamily } + val fallbackCount = patchSites.size - resolvedCount + logger.info(" ↳ Post-rename safety net: patched ${patchSites.size} missed call site(s) for `$oldName` → `$newName`") + logger.info(" resolved-to-family: $resolvedCount, qualifier-fallback: $fallbackCount") + patchSites.take(10).forEach { p -> + val tag = if (p.viaResolvedFamily) "resolved-to-family" else "qualifier-fallback" + logger.info(" ${p.path}:${p.line} ($tag)") + } + if (patchSites.size > 10) { + logger.info(" ... (${patchSites.size - 10} more)") + } + return patchSites.size } /** diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/psi/WildcardImportExpander.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/psi/WildcardImportExpander.kt new file mode 100644 index 0000000..d1e6b47 --- /dev/null +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/psi/WildcardImportExpander.kt @@ -0,0 +1,166 @@ +package com.github.pderakhshanfar.codecocoonplugin.intellij.psi + +import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout +import com.intellij.ide.highlighter.JavaFileType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.readAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.JavaRecursiveElementVisitor +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiImportList +import com.intellij.psi.PsiImportStatement +import com.intellij.psi.PsiImportStaticStatement +import com.intellij.psi.PsiJavaCodeReferenceElement +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiManager +import com.intellij.psi.PsiMember +import com.intellij.psi.PsiPackage +import com.intellij.psi.PsiReferenceExpression +import com.intellij.psi.search.FileTypeIndex +import com.intellij.psi.search.GlobalSearchScope +import kotlinx.coroutines.runBlocking + +/** + * One-shot pre-processing utility that replaces wildcard imports with explicit + * single imports for symbols actually used in the file. + * + * Why this exists: IntelliJ's `RenameProcessor` invokes + * `JavaCodeStyleManager.shortenClassReferences()` on every file whose references + * it rewrites. That call can also strip `import static X.*;` lines on the + * touched files even when those lines are load-bearing — observed on + * fastjson2 PR-82 where the rename of `JSON` → `JsonCodec` removed + * `import static junit.framework.TestCase.*;` from test files, breaking + * `assertNull(...)` resolution. There is no documented IntelliJ toggle for + * this side effect (`IDEABKL-3561`). + * + * Pre-expanding wildcards into explicit single imports defangs the optimizer: + * each remaining import points at a name PSI sees as referenced, so the + * optimizer cannot drop it as "unused". + * + * Run once per project, before any transformation, across every Java file in + * project scope (not just files we transform — RenameProcessor can touch + * cross-module references in files we never enumerate). + */ +object WildcardImportExpander { + private val logger = thisLogger().withStdout() + + data class Stats(val filesScanned: Int, val wildcardsExpanded: Int) + + fun expandAll(project: Project): Stats { + val scope = GlobalSearchScope.projectScope(project) + val files = runBlocking { readAction { FileTypeIndex.getFiles(JavaFileType.INSTANCE, scope) } } + val psiManager = PsiManager.getInstance(project) + + var scanned = 0 + var expanded = 0 + for (vf in files) { + val psiFile = runBlocking { readAction { psiManager.findFile(vf) as? PsiJavaFile } } ?: continue + scanned++ + expanded += expandInFile(project, psiFile) + } + logger.info("[WildcardImportExpander] Pre-processed $scanned files; expanded $expanded wildcard import(s)") + return Stats(scanned, expanded) + } + + private fun expandInFile(project: Project, psiFile: PsiJavaFile): Int { + data class Plan( + val importList: PsiImportList, + val staticWildcards: List, + val regularWildcards: List, + ) + + val plan = runBlocking { + readAction { + val importList = psiFile.importList ?: return@readAction null + val statics = importList.importStaticStatements.filter { it.isOnDemand }.toList() + val regulars = importList.importStatements.filter { it.isOnDemand }.toList() + if (statics.isEmpty() && regulars.isEmpty()) null + else Plan(importList, statics, regulars) + } + } ?: return 0 + + var count = 0 + for (w in plan.staticWildcards) count += rewriteStaticWildcard(project, psiFile, plan.importList, w) + for (w in plan.regularWildcards) count += rewriteRegularWildcard(project, psiFile, plan.importList, w) + return count + } + + private fun rewriteStaticWildcard( + project: Project, + psiFile: PsiJavaFile, + importList: PsiImportList, + wildcard: PsiImportStaticStatement, + ): Int { + val targetClass = runBlocking { readAction { wildcard.resolveTargetClass() } } ?: return 0 + val usedNames = runBlocking { readAction { collectStaticUses(psiFile, targetClass) } } + + ApplicationManager.getApplication().invokeAndWait { + WriteCommandAction.runWriteCommandAction(project) { + if (!wildcard.isValid) return@runWriteCommandAction + val factory = JavaPsiFacade.getElementFactory(project) + for (name in usedNames) { + importList.add(factory.createImportStaticStatement(targetClass, name)) + } + wildcard.delete() + } + } + return 1 + } + + private fun rewriteRegularWildcard( + project: Project, + psiFile: PsiJavaFile, + importList: PsiImportList, + wildcard: PsiImportStatement, + ): Int { + val pkg = runBlocking { readAction { wildcard.importReference?.resolve() as? PsiPackage } } ?: return 0 + val usedClasses = runBlocking { readAction { collectClassUses(psiFile, pkg) } } + + ApplicationManager.getApplication().invokeAndWait { + WriteCommandAction.runWriteCommandAction(project) { + if (!wildcard.isValid) return@runWriteCommandAction + val factory = JavaPsiFacade.getElementFactory(project) + for (cls in usedClasses) { + importList.add(factory.createImportStatement(cls)) + } + wildcard.delete() + } + } + return 1 + } + + private fun collectStaticUses(psiFile: PsiJavaFile, targetClass: PsiClass): Set { + val names = LinkedHashSet() + psiFile.accept(object : JavaRecursiveElementVisitor() { + override fun visitReferenceExpression(expr: PsiReferenceExpression) { + super.visitReferenceExpression(expr) + if (expr.qualifierExpression != null) return + val resolved = expr.resolve() + if (resolved is PsiMember && resolved.containingClass == targetClass) { + expr.referenceName?.let { names.add(it) } + } + } + }) + return names + } + + private fun collectClassUses(psiFile: PsiJavaFile, pkg: PsiPackage): Set { + val classes = LinkedHashSet() + val pkgFqn = pkg.qualifiedName + psiFile.accept(object : JavaRecursiveElementVisitor() { + override fun visitReferenceElement(reference: PsiJavaCodeReferenceElement) { + super.visitReferenceElement(reference) + if (reference.qualifier != null) return + val resolved = reference.resolve() as? PsiClass ?: return + val fqn = resolved.qualifiedName ?: return + if (fqn.substringBeforeLast('.', "") == pkgFqn) { + classes.add(resolved) + } + } + }) + return classes + } +} diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt index fe785f3..6fbd9b8 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt @@ -7,6 +7,7 @@ import com.github.pderakhshanfar.codecocoonplugin.components.executor.IntelliJTr import com.github.pderakhshanfar.codecocoonplugin.config.CodeCocoonConfig import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout +import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.WildcardImportExpander import com.github.pderakhshanfar.codecocoonplugin.intellij.vfs.findVirtualFile import com.github.pderakhshanfar.codecocoonplugin.intellij.vfs.relativeToRootOrAbsPath import com.github.pderakhshanfar.codecocoonplugin.memory.PersistentMemory @@ -130,6 +131,13 @@ class TransformationService { ) { logger.info("[TransformationService] Applying ${transformations.size} transformations") + // Pre-expand wildcard imports project-wide so RenameProcessor's post-rename + // import optimizer can't strip a wildcard line on files it touches and + // leave referenced symbols (e.g. `assertNull` from + // `import static junit.framework.TestCase.*;`) unresolved. + logger.info("[TransformationService] Pre-expanding wildcard imports project-wide...") + WildcardImportExpander.expandAll(project) + val files = listProjectFiles(project, config.projectRoot, includeOnly = config.files) val executor = IntelliJTransformationExecutor(project) From a913a4d209ba1f0cf26b6697517b813ef75c1bcf Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sun, 3 May 2026 17:16:48 +0200 Subject: [PATCH 43/67] fix: skip patching method references inside annotation arguments Prevent accidental modifications by adding a check to exclude method reference identifiers located within annotation arguments during post-rename patching. --- .../transformations/renaming/RenameMethodTransformation.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index bd2841c..5a2a854 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -28,6 +28,7 @@ import com.intellij.psi.* import com.intellij.psi.search.FileTypeIndex import com.intellij.psi.search.GlobalSearchScope import com.intellij.psi.search.searches.ReferencesSearch +import com.intellij.psi.util.PsiTreeUtil import com.intellij.refactoring.rename.RenameProcessor import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable @@ -525,6 +526,11 @@ class RenameMethodTransformation( qualifierClass?.qualifiedName == containingFqn if (resolvesToFamily || (resolved == null && qualifierMatchesContainingClass)) { + // Defensive: never patch identifiers inside annotation args + // (annotation member references, not overload-resolved calls). + if (PsiTreeUtil.getParentOfType(expr, PsiAnnotation::class.java) != null) { + return + } val id = refExpr.referenceNameElement as? PsiIdentifier ?: return val line = document?.getLineNumber(id.textRange.startOffset)?.plus(1) ?: -1 patchSites.add(PatchSite(id, vf.path, line, resolvesToFamily)) From bc24944763c3d030516b4f7d5019c80d741e340d Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sun, 3 May 2026 17:37:01 +0200 Subject: [PATCH 44/67] fix: WildcardImportExpander attribution under static inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version used `(resolved as PsiMember).containingClass == targetClass` to attribute references to wildcards. Wrong because `import static X.*;` inherits — `TestCase` exposes `assertNull` declared on its super `Assert`, and PSI's resolver returns the declaring class. Two failure modes on fastjson2 PR-82: - Empty replacements: `Issue1344.java` only uses `assertNull(String)`. The resolver returned a member on `junit.framework.Assert`, equality rejected it, the wildcard was deleted with no single-import replacement. - Multi-wildcard drops: files with both `org.junit.jupiter.api.Assertions.*` and `junit.framework.TestCase.*` lost names like `assertEquals(int,int)` from one of the two expansions because the resolver picked one origin. Replace the equality check with a positive query against the target class's visible (inherited) members via `findMethodsByName(checkBases = true)` / `findFieldByName(checkBases = true)` / `findInnerClassByName(checkBases = true)`. Walk the file once, split unqualified refs into resolved / unresolved name sets, then per wildcard intersect with what the target class exposes. The same name can land in multiple wildcards' expansions — correct, since both originals exposed it. Conservative keep: if a wildcard's `usedNames` is empty AND any unresolved reference in the file matches a name the target class would expose, leave the wildcard untouched. Better to retain a working wildcard than delete a load-bearing one. Stats now report `expanded N; kept M as conservative`. --- .../intellij/psi/WildcardImportExpander.kt | 140 ++++++++++++++---- 1 file changed, 109 insertions(+), 31 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/psi/WildcardImportExpander.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/psi/WildcardImportExpander.kt index d1e6b47..f747dec 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/psi/WildcardImportExpander.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/psi/WildcardImportExpander.kt @@ -43,11 +43,18 @@ import kotlinx.coroutines.runBlocking * Run once per project, before any transformation, across every Java file in * project scope (not just files we transform — RenameProcessor can touch * cross-module references in files we never enumerate). + * + * **Static-inheritance attribution:** `import static X.*;` legitimately exposes + * any static defined on `X` OR any of its supers (e.g. `TestCase` exposes + * `assertNull` declared on `Assert`). PSI's resolver returns the *declaring* + * class, not the imported one. We therefore attribute usage by querying the + * target class's visible (inherited) members instead of comparing + * `containingClass`. */ object WildcardImportExpander { private val logger = thisLogger().withStdout() - data class Stats(val filesScanned: Int, val wildcardsExpanded: Int) + data class Stats(val filesScanned: Int, val wildcardsExpanded: Int, val wildcardsKept: Int) fun expandAll(project: Project): Stats { val scope = GlobalSearchScope.projectScope(project) @@ -56,20 +63,36 @@ object WildcardImportExpander { var scanned = 0 var expanded = 0 + var kept = 0 for (vf in files) { val psiFile = runBlocking { readAction { psiManager.findFile(vf) as? PsiJavaFile } } ?: continue scanned++ - expanded += expandInFile(project, psiFile) + val (e, k) = expandInFile(project, psiFile) + expanded += e + kept += k } - logger.info("[WildcardImportExpander] Pre-processed $scanned files; expanded $expanded wildcard import(s)") - return Stats(scanned, expanded) + logger.info("[WildcardImportExpander] Pre-processed $scanned files; expanded $expanded wildcard import(s); kept $kept wildcard(s) as conservative") + return Stats(scanned, expanded, kept) } - private fun expandInFile(project: Project, psiFile: PsiJavaFile): Int { + /** + * Per-file static-reference summary built in a single PSI walk, so we can + * attribute names to multiple wildcards without re-walking. + */ + private data class StaticRefSummary( + /** Names of unqualified references that resolved to some PsiMember. */ + val resolvedNames: Set, + /** Names of unqualified references whose resolution returned null. */ + val unresolvedNames: Set, + ) + + /** @return (expanded, kept) counters for the file. */ + private fun expandInFile(project: Project, psiFile: PsiJavaFile): Pair { data class Plan( val importList: PsiImportList, val staticWildcards: List, val regularWildcards: List, + val staticSummary: StaticRefSummary, ) val plan = runBlocking { @@ -78,24 +101,82 @@ object WildcardImportExpander { val statics = importList.importStaticStatements.filter { it.isOnDemand }.toList() val regulars = importList.importStatements.filter { it.isOnDemand }.toList() if (statics.isEmpty() && regulars.isEmpty()) null - else Plan(importList, statics, regulars) + else Plan(importList, statics, regulars, summarizeStaticRefs(psiFile)) } - } ?: return 0 + } ?: return 0 to 0 - var count = 0 - for (w in plan.staticWildcards) count += rewriteStaticWildcard(project, psiFile, plan.importList, w) - for (w in plan.regularWildcards) count += rewriteRegularWildcard(project, psiFile, plan.importList, w) - return count + var expanded = 0 + var kept = 0 + for (w in plan.staticWildcards) { + val (e, k) = rewriteStaticWildcard(project, plan.importList, w, plan.staticSummary) + expanded += e + kept += k + } + for (w in plan.regularWildcards) { + expanded += rewriteRegularWildcard(project, psiFile, plan.importList, w) + } + return expanded to kept } + /** + * Walk the file once. For every unqualified `PsiReferenceExpression`, + * record its name as either resolved (resolves to some `PsiMember`) or + * unresolved. Filtering per wildcard happens later by querying the target + * class's visible members. + */ + private fun summarizeStaticRefs(psiFile: PsiJavaFile): StaticRefSummary { + val resolved = LinkedHashSet() + val unresolved = LinkedHashSet() + psiFile.accept(object : JavaRecursiveElementVisitor() { + override fun visitReferenceExpression(expr: PsiReferenceExpression) { + super.visitReferenceExpression(expr) + if (expr.qualifierExpression != null) return + val name = expr.referenceName ?: return + val target = expr.resolve() + when { + target is PsiMember -> resolved.add(name) + target == null -> unresolved.add(name) + } + } + }) + return StaticRefSummary(resolved, unresolved) + } + + /** + * Rewrite one static wildcard. + * + * @return (1, 0) on expansion, (0, 1) on conservative keep, (0, 0) on + * unresolvable target (no change). + */ private fun rewriteStaticWildcard( project: Project, - psiFile: PsiJavaFile, importList: PsiImportList, wildcard: PsiImportStaticStatement, - ): Int { - val targetClass = runBlocking { readAction { wildcard.resolveTargetClass() } } ?: return 0 - val usedNames = runBlocking { readAction { collectStaticUses(psiFile, targetClass) } } + summary: StaticRefSummary, + ): Pair { + val targetClass = runBlocking { readAction { wildcard.resolveTargetClass() } } ?: return 0 to 0 + + // Names that target class exposes (incl. inherited) that are referenced + // in this file. Same name may also be exposed by another wildcard's + // target class — that's correct, we record it for both. + val usedNames = runBlocking { + readAction { + summary.resolvedNames.filter { name -> targetClassExposes(targetClass, name) } + } + } + + // Conservative keep: if any unresolved reference matches a name this + // target class would expose, the wildcard might be load-bearing for + // a name PSI couldn't bind right now. Don't delete it. + val coversUnresolved = runBlocking { + readAction { + summary.unresolvedNames.any { name -> targetClassExposes(targetClass, name) } + } + } + + if (usedNames.isEmpty() && coversUnresolved) { + return 0 to 1 + } ApplicationManager.getApplication().invokeAndWait { WriteCommandAction.runWriteCommandAction(project) { @@ -107,7 +188,19 @@ object WildcardImportExpander { wildcard.delete() } } - return 1 + return 1 to 0 + } + + /** + * Does `targetClass` expose a static member with this simple name through + * its visible (inherited) members? Methods, fields, and inner classes all + * count as importable via `import static X.*;`. + */ + private fun targetClassExposes(targetClass: PsiClass, name: String): Boolean { + if (targetClass.findMethodsByName(name, /* checkBases = */ true).isNotEmpty()) return true + if (targetClass.findFieldByName(name, /* checkBases = */ true) != null) return true + if (targetClass.findInnerClassByName(name, /* checkBases = */ true) != null) return true + return false } private fun rewriteRegularWildcard( @@ -132,21 +225,6 @@ object WildcardImportExpander { return 1 } - private fun collectStaticUses(psiFile: PsiJavaFile, targetClass: PsiClass): Set { - val names = LinkedHashSet() - psiFile.accept(object : JavaRecursiveElementVisitor() { - override fun visitReferenceExpression(expr: PsiReferenceExpression) { - super.visitReferenceExpression(expr) - if (expr.qualifierExpression != null) return - val resolved = expr.resolve() - if (resolved is PsiMember && resolved.containingClass == targetClass) { - expr.referenceName?.let { names.add(it) } - } - } - }) - return names - } - private fun collectClassUses(psiFile: PsiJavaFile, pkg: PsiPackage): Set { val classes = LinkedHashSet() val pkgFqn = pkg.qualifiedName From 2d21b709e7f9095cb0e45dc6049b83d023817d6e Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sun, 3 May 2026 17:57:16 +0200 Subject: [PATCH 45/67] fix: harden WildcardImportExpander against stale PSI element references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version captured `List` once, then iterated and rewrote each in its own `WriteCommandAction`. After the first WriteCommandAction (`importList.add` × N + `wildcard.delete()`), the OTHER captured `PsiImportStaticStatement` siblings became invalidated. The next iteration's `wildcard.resolveTargetClass()` then threw `PsiInvalidElementAccessException: containing file is null` and the entire transformation pipeline aborted before even creating the memory file — observed on fastjson2 PR-82 right after the "[TransformationService] Pre-expanding wildcard imports project-wide..." log line. Structural fix — never hold PSI element references across mutations: - The captured plan now stores `staticTargetFqns: List` and `regularPackageFqns: List` instead of element references. - Inside each rewrite's `WriteCommandAction` the wildcard is re-located by scanning the live `importList.importStaticStatements` / `importStatements` and matching by `importReference.qualifiedName` + `isOnDemand` + `isValid`. If the wildcard is gone, we silently skip. - The target `PsiClass` is re-resolved fresh inside the WriteCommandAction via `JavaPsiFacade.findClass(fqn, allScope)`. Defense in depth — the expander is best-effort and never aborts the pipeline: - New `safeReadAction(fallback) { ... }` helper wraps every read action, rethrowing only `ProcessCanceledException` / `InterruptedException`. - `expandAll` and `expandInFile` wrap per-file / per-wildcard work in `try/catch (Throwable)`, logging WARN with file path + FQN and bumping `filesFailed` / `wildcardsFailed` counters. - The visitor blocks in `summarizeStaticRefs` and `collectClassUses` now swallow per-node throwables. - `Stats` extended with `filesFailed` and `wildcardsFailed`; final log line: "Pre-processed N files; expanded M; kept K conservative; failed F file(s) / W wildcard(s)". --- .../intellij/psi/WildcardImportExpander.kt | 340 ++++++++++++------ 1 file changed, 226 insertions(+), 114 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/psi/WildcardImportExpander.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/psi/WildcardImportExpander.kt index f747dec..81a5e30 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/psi/WildcardImportExpander.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/psi/WildcardImportExpander.kt @@ -6,6 +6,7 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.readAction import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.Project import com.intellij.psi.JavaPsiFacade import com.intellij.psi.JavaRecursiveElementVisitor @@ -50,128 +51,175 @@ import kotlinx.coroutines.runBlocking * class, not the imported one. We therefore attribute usage by querying the * target class's visible (inherited) members instead of comparing * `containingClass`. + * + * **PSI-stability discipline:** Each `WriteCommandAction` that mutates the + * import list invalidates other `PsiImportStatement` siblings even when they + * weren't the deleted one. We therefore never hold a `PsiImportStatement` / + * `PsiImportStaticStatement` reference across mutations — instead we capture + * the wildcards' target FQNs once, then re-locate the matching wildcard inside + * each rewrite by scanning the (now-fresh) import list. + * + * **Best-effort:** All PSI operations are wrapped in `try/catch (Throwable)`, + * rethrowing only `ProcessCanceledException` / `InterruptedException`. A + * single bad file or wildcard never aborts the pipeline. */ object WildcardImportExpander { private val logger = thisLogger().withStdout() - data class Stats(val filesScanned: Int, val wildcardsExpanded: Int, val wildcardsKept: Int) + data class Stats( + val filesScanned: Int, + val wildcardsExpanded: Int, + val wildcardsKept: Int, + val filesFailed: Int, + val wildcardsFailed: Int, + ) fun expandAll(project: Project): Stats { - val scope = GlobalSearchScope.projectScope(project) - val files = runBlocking { readAction { FileTypeIndex.getFiles(JavaFileType.INSTANCE, scope) } } + val files = safeReadAction(emptyList()) { + FileTypeIndex.getFiles(JavaFileType.INSTANCE, GlobalSearchScope.projectScope(project)).toList() + } val psiManager = PsiManager.getInstance(project) var scanned = 0 var expanded = 0 var kept = 0 + var filesFailed = 0 + var wildcardsFailed = 0 + for (vf in files) { - val psiFile = runBlocking { readAction { psiManager.findFile(vf) as? PsiJavaFile } } ?: continue - scanned++ - val (e, k) = expandInFile(project, psiFile) - expanded += e - kept += k + val path = vf.path + try { + val psiFile = safeReadAction(null) { psiManager.findFile(vf) as? PsiJavaFile } ?: continue + scanned++ + val (e, k, wf) = expandInFile(project, psiFile) + expanded += e + kept += k + wildcardsFailed += wf + } catch (e: ProcessCanceledException) { + throw e + } catch (e: InterruptedException) { + throw e + } catch (t: Throwable) { + filesFailed++ + logger.warn("[WildcardImportExpander] Failed to process file '$path': ${t.javaClass.simpleName}: ${t.message}") + } } - logger.info("[WildcardImportExpander] Pre-processed $scanned files; expanded $expanded wildcard import(s); kept $kept wildcard(s) as conservative") - return Stats(scanned, expanded, kept) + logger.info( + "[WildcardImportExpander] Pre-processed $scanned files; expanded $expanded; kept $kept conservative; " + + "failed $filesFailed file(s) / $wildcardsFailed wildcard(s)" + ) + return Stats(scanned, expanded, kept, filesFailed, wildcardsFailed) } - /** - * Per-file static-reference summary built in a single PSI walk, so we can - * attribute names to multiple wildcards without re-walking. - */ + /** Per-file static-reference summary built in a single PSI walk. */ private data class StaticRefSummary( - /** Names of unqualified references that resolved to some PsiMember. */ val resolvedNames: Set, - /** Names of unqualified references whose resolution returned null. */ val unresolvedNames: Set, ) - /** @return (expanded, kept) counters for the file. */ - private fun expandInFile(project: Project, psiFile: PsiJavaFile): Pair { + /** @return Triple(expanded, kept, wildcardsFailed) for the file. */ + private fun expandInFile(project: Project, psiFile: PsiJavaFile): Triple { + // Capture by FQN, NOT by PsiElement reference — element references can + // be invalidated by sibling mutations during rewrite. data class Plan( - val importList: PsiImportList, - val staticWildcards: List, - val regularWildcards: List, + val staticTargetFqns: List, + val regularPackageFqns: List, val staticSummary: StaticRefSummary, ) - val plan = runBlocking { - readAction { - val importList = psiFile.importList ?: return@readAction null - val statics = importList.importStaticStatements.filter { it.isOnDemand }.toList() - val regulars = importList.importStatements.filter { it.isOnDemand }.toList() - if (statics.isEmpty() && regulars.isEmpty()) null - else Plan(importList, statics, regulars, summarizeStaticRefs(psiFile)) - } - } ?: return 0 to 0 + val plan = safeReadAction(null) { + val importList = psiFile.importList ?: return@safeReadAction null + val statics = importList.importStaticStatements + .filter { it.isOnDemand && it.isValid } + .mapNotNull { it.importReference?.qualifiedName } + .toList() + val regulars = importList.importStatements + .filter { it.isOnDemand && it.isValid } + .mapNotNull { it.importReference?.qualifiedName } + .toList() + if (statics.isEmpty() && regulars.isEmpty()) null + else Plan(statics, regulars, summarizeStaticRefs(psiFile)) + } ?: return Triple(0, 0, 0) var expanded = 0 var kept = 0 - for (w in plan.staticWildcards) { - val (e, k) = rewriteStaticWildcard(project, plan.importList, w, plan.staticSummary) - expanded += e - kept += k + var failed = 0 + for (fqn in plan.staticTargetFqns) { + try { + val (e, k) = rewriteStaticWildcard(project, psiFile, fqn, plan.staticSummary) + expanded += e + kept += k + } catch (e: ProcessCanceledException) { + throw e + } catch (e: InterruptedException) { + throw e + } catch (t: Throwable) { + failed++ + logger.warn("[WildcardImportExpander] Failed to expand static wildcard '$fqn' in '${psiFile.virtualFile?.path}': ${t.javaClass.simpleName}: ${t.message}") + } } - for (w in plan.regularWildcards) { - expanded += rewriteRegularWildcard(project, psiFile, plan.importList, w) + for (fqn in plan.regularPackageFqns) { + try { + expanded += rewriteRegularWildcard(project, psiFile, fqn) + } catch (e: ProcessCanceledException) { + throw e + } catch (e: InterruptedException) { + throw e + } catch (t: Throwable) { + failed++ + logger.warn("[WildcardImportExpander] Failed to expand regular wildcard '$fqn' in '${psiFile.virtualFile?.path}': ${t.javaClass.simpleName}: ${t.message}") + } } - return expanded to kept + return Triple(expanded, kept, failed) } - /** - * Walk the file once. For every unqualified `PsiReferenceExpression`, - * record its name as either resolved (resolves to some `PsiMember`) or - * unresolved. Filtering per wildcard happens later by querying the target - * class's visible members. - */ private fun summarizeStaticRefs(psiFile: PsiJavaFile): StaticRefSummary { val resolved = LinkedHashSet() val unresolved = LinkedHashSet() - psiFile.accept(object : JavaRecursiveElementVisitor() { - override fun visitReferenceExpression(expr: PsiReferenceExpression) { - super.visitReferenceExpression(expr) - if (expr.qualifierExpression != null) return - val name = expr.referenceName ?: return - val target = expr.resolve() - when { - target is PsiMember -> resolved.add(name) - target == null -> unresolved.add(name) + try { + psiFile.accept(object : JavaRecursiveElementVisitor() { + override fun visitReferenceExpression(expr: PsiReferenceExpression) { + super.visitReferenceExpression(expr) + try { + if (expr.qualifierExpression != null) return + val name = expr.referenceName ?: return + val target = expr.resolve() + when { + target is PsiMember -> resolved.add(name) + target == null -> unresolved.add(name) + } + } catch (e: ProcessCanceledException) { + throw e + } catch (_: Throwable) { + // ignore — best-effort summarisation + } } - } - }) + }) + } catch (e: ProcessCanceledException) { + throw e + } catch (_: Throwable) { + // ignore — return what we have + } return StaticRefSummary(resolved, unresolved) } - /** - * Rewrite one static wildcard. - * - * @return (1, 0) on expansion, (0, 1) on conservative keep, (0, 0) on - * unresolvable target (no change). - */ + /** @return Pair(expanded, kept). */ private fun rewriteStaticWildcard( project: Project, - importList: PsiImportList, - wildcard: PsiImportStaticStatement, + psiFile: PsiJavaFile, + targetFqn: String, summary: StaticRefSummary, ): Pair { - val targetClass = runBlocking { readAction { wildcard.resolveTargetClass() } } ?: return 0 to 0 - - // Names that target class exposes (incl. inherited) that are referenced - // in this file. Same name may also be exposed by another wildcard's - // target class — that's correct, we record it for both. - val usedNames = runBlocking { - readAction { - summary.resolvedNames.filter { name -> targetClassExposes(targetClass, name) } - } - } + val targetClass = safeReadAction(null) { + JavaPsiFacade.getInstance(project) + .findClass(targetFqn, GlobalSearchScope.allScope(project)) + } ?: return 0 to 0 - // Conservative keep: if any unresolved reference matches a name this - // target class would expose, the wildcard might be load-bearing for - // a name PSI couldn't bind right now. Don't delete it. - val coversUnresolved = runBlocking { - readAction { - summary.unresolvedNames.any { name -> targetClassExposes(targetClass, name) } - } + val usedNames = safeReadAction(emptyList()) { + summary.resolvedNames.filter { name -> targetClassExposes(targetClass, name) } + } + val coversUnresolved = safeReadAction(false) { + summary.unresolvedNames.any { name -> targetClassExposes(targetClass, name) } } if (usedNames.isEmpty() && coversUnresolved) { @@ -179,47 +227,79 @@ object WildcardImportExpander { } ApplicationManager.getApplication().invokeAndWait { - WriteCommandAction.runWriteCommandAction(project) { - if (!wildcard.isValid) return@runWriteCommandAction - val factory = JavaPsiFacade.getElementFactory(project) - for (name in usedNames) { - importList.add(factory.createImportStaticStatement(targetClass, name)) + try { + WriteCommandAction.runWriteCommandAction(project) { + val importList = psiFile.importList ?: return@runWriteCommandAction + val factory = JavaPsiFacade.getElementFactory(project) + val freshTargetClass = JavaPsiFacade.getInstance(project) + .findClass(targetFqn, GlobalSearchScope.allScope(project)) + ?: return@runWriteCommandAction + // Re-locate the wildcard fresh — sibling mutations may have + // invalidated whatever element we saw earlier. + val wildcard = importList.importStaticStatements.firstOrNull { stmt -> + stmt.isValid && stmt.isOnDemand && + runCatching { stmt.importReference?.qualifiedName }.getOrNull() == targetFqn + } ?: return@runWriteCommandAction + + for (name in usedNames) { + importList.add(factory.createImportStaticStatement(freshTargetClass, name)) + } + if (wildcard.isValid) wildcard.delete() } - wildcard.delete() + } catch (e: ProcessCanceledException) { + throw e + } catch (t: Throwable) { + logger.warn("[WildcardImportExpander] WriteCommandAction failed for static '$targetFqn': ${t.javaClass.simpleName}: ${t.message}") } } return 1 to 0 } - /** - * Does `targetClass` expose a static member with this simple name through - * its visible (inherited) members? Methods, fields, and inner classes all - * count as importable via `import static X.*;`. - */ private fun targetClassExposes(targetClass: PsiClass, name: String): Boolean { - if (targetClass.findMethodsByName(name, /* checkBases = */ true).isNotEmpty()) return true - if (targetClass.findFieldByName(name, /* checkBases = */ true) != null) return true - if (targetClass.findInnerClassByName(name, /* checkBases = */ true) != null) return true - return false + return try { + if (targetClass.findMethodsByName(name, /* checkBases = */ true).isNotEmpty()) return true + if (targetClass.findFieldByName(name, /* checkBases = */ true) != null) return true + if (targetClass.findInnerClassByName(name, /* checkBases = */ true) != null) return true + false + } catch (e: ProcessCanceledException) { + throw e + } catch (_: Throwable) { + false + } } private fun rewriteRegularWildcard( project: Project, psiFile: PsiJavaFile, - importList: PsiImportList, - wildcard: PsiImportStatement, + packageFqn: String, ): Int { - val pkg = runBlocking { readAction { wildcard.importReference?.resolve() as? PsiPackage } } ?: return 0 - val usedClasses = runBlocking { readAction { collectClassUses(psiFile, pkg) } } + val pkg = safeReadAction(null) { + JavaPsiFacade.getInstance(project).findPackage(packageFqn) + } ?: return 0 + + val usedClasses = safeReadAction(emptyList()) { + collectClassUses(psiFile, pkg).toList() + } ApplicationManager.getApplication().invokeAndWait { - WriteCommandAction.runWriteCommandAction(project) { - if (!wildcard.isValid) return@runWriteCommandAction - val factory = JavaPsiFacade.getElementFactory(project) - for (cls in usedClasses) { - importList.add(factory.createImportStatement(cls)) + try { + WriteCommandAction.runWriteCommandAction(project) { + val importList = psiFile.importList ?: return@runWriteCommandAction + val factory = JavaPsiFacade.getElementFactory(project) + val wildcard = importList.importStatements.firstOrNull { stmt -> + stmt.isValid && stmt.isOnDemand && + runCatching { stmt.importReference?.qualifiedName }.getOrNull() == packageFqn + } ?: return@runWriteCommandAction + + for (cls in usedClasses) { + if (cls.isValid) importList.add(factory.createImportStatement(cls)) + } + if (wildcard.isValid) wildcard.delete() } - wildcard.delete() + } catch (e: ProcessCanceledException) { + throw e + } catch (t: Throwable) { + logger.warn("[WildcardImportExpander] WriteCommandAction failed for regular '$packageFqn': ${t.javaClass.simpleName}: ${t.message}") } } return 1 @@ -228,17 +308,49 @@ object WildcardImportExpander { private fun collectClassUses(psiFile: PsiJavaFile, pkg: PsiPackage): Set { val classes = LinkedHashSet() val pkgFqn = pkg.qualifiedName - psiFile.accept(object : JavaRecursiveElementVisitor() { - override fun visitReferenceElement(reference: PsiJavaCodeReferenceElement) { - super.visitReferenceElement(reference) - if (reference.qualifier != null) return - val resolved = reference.resolve() as? PsiClass ?: return - val fqn = resolved.qualifiedName ?: return - if (fqn.substringBeforeLast('.', "") == pkgFqn) { - classes.add(resolved) + try { + psiFile.accept(object : JavaRecursiveElementVisitor() { + override fun visitReferenceElement(reference: PsiJavaCodeReferenceElement) { + super.visitReferenceElement(reference) + try { + if (reference.qualifier != null) return + val resolved = reference.resolve() as? PsiClass ?: return + val fqn = resolved.qualifiedName ?: return + if (fqn.substringBeforeLast('.', "") == pkgFqn) { + classes.add(resolved) + } + } catch (e: ProcessCanceledException) { + throw e + } catch (_: Throwable) { + // ignore + } } - } - }) + }) + } catch (e: ProcessCanceledException) { + throw e + } catch (_: Throwable) { + // ignore + } return classes } + + /** + * Run a read-action computation, swallowing any non-control-flow throwable + * and returning [fallback]. We deliberately do NOT rethrow PSI / IDE + * exceptions here — they'd abort the pipeline. Cancellation must still + * propagate, hence the explicit rethrow for `ProcessCanceledException` / + * `InterruptedException`. + */ + private fun safeReadAction(fallback: T, block: () -> T): T { + return try { + runBlocking { readAction { block() } } + } catch (e: ProcessCanceledException) { + throw e + } catch (e: InterruptedException) { + throw e + } catch (t: Throwable) { + logger.warn("[WildcardImportExpander] readAction failed: ${t.javaClass.simpleName}: ${t.message}") + fallback + } + } } From 09a81ef65a11d00e2d39a5c9ecc0ace333fe461f Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Sun, 3 May 2026 18:07:58 +0200 Subject: [PATCH 46/67] refactor: comment out use of `WildcardImportExpander` --- .../codecocoonplugin/services/TransformationService.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt index 6fbd9b8..3427f3f 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt @@ -135,8 +135,10 @@ class TransformationService { // import optimizer can't strip a wildcard line on files it touches and // leave referenced symbols (e.g. `assertNull` from // `import static junit.framework.TestCase.*;`) unresolved. - logger.info("[TransformationService] Pre-expanding wildcard imports project-wide...") - WildcardImportExpander.expandAll(project) + + // TODO: WildcardImportExpander misses some imports -> skip it + // logger.info("[TransformationService] Pre-expanding wildcard imports project-wide...") + // WildcardImportExpander.expandAll(project) val files = listProjectFiles(project, config.projectRoot, includeOnly = config.files) val executor = IntelliJTransformationExecutor(project) From e39845c060e0dda9da411f49de3e67c73cbb11cc Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Mon, 4 May 2026 14:16:16 +0200 Subject: [PATCH 47/67] fix: add timeout handling for move operations to avoid indefinite hangs - Introduced `TimeoutException` handling on move operations with a 3-minute limit. - Added logging to warn about timed-out suggestions before proceeding to the next. --- .../MoveFileIntoSuggestedDirectoryTransformation.kt | 13 +++++++++++-- .../services/TransformationService.kt | 1 - 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt index 507444e..7fb24e1 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt @@ -31,6 +31,8 @@ import com.intellij.util.containers.MultiMap import kotlinx.coroutines.runBlocking import java.nio.file.Paths import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException import kotlin.collections.iterator @@ -277,8 +279,15 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( } // finish when moved successfully into the current suggestion - logger.info(" ↳ Awaiting completion of move operation (i.e., `successfullyMoved.join()`)...") - val moveResult = successfullyMoved.join() + logger.info(" ↳ Awaiting completion of move operation (i.e., `successfullyMoved.get(3min)`)...") + + val moveResult = try { + successfullyMoved.get(3, TimeUnit.MINUTES) + } catch (_: TimeoutException) { + logger.warn(" ⚠️ [TIMEOUT] Suggestion #${index + 1} for '$filename' timed out after 3 minutes; moving on to next suggestion") + false + } + logger.info(" ↳ Move operation for suggestion #${index + 1} for '$filename' ${if (moveResult) "succeeded" else "failed"}: '$suggestedDirectory'") if (moveResult) { val (filesModified, usageSummary) = withReadAction { diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt index 3427f3f..0b07f9c 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt @@ -7,7 +7,6 @@ import com.github.pderakhshanfar.codecocoonplugin.components.executor.IntelliJTr import com.github.pderakhshanfar.codecocoonplugin.config.CodeCocoonConfig import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout -import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.WildcardImportExpander import com.github.pderakhshanfar.codecocoonplugin.intellij.vfs.findVirtualFile import com.github.pderakhshanfar.codecocoonplugin.intellij.vfs.relativeToRootOrAbsPath import com.github.pderakhshanfar.codecocoonplugin.memory.PersistentMemory From 8d7e4cfde1731be23638547f15baeba7fc653950 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Mon, 4 May 2026 23:37:14 +0200 Subject: [PATCH 48/67] feat: add agent for fixing import reordering/wildcard import removals (untested) --- build.gradle.kts | 25 +++ .../suggestions/impl/FixImportHunks.kt | 181 ++++++++++++++++++ .../appstarter/FixHunksStarter.kt | 126 ++++++++++++ src/main/resources/META-INF/plugin.xml | 2 + 4 files changed, 334 insertions(+) create mode 100644 core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt create mode 100644 src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt diff --git a/build.gradle.kts b/build.gradle.kts index 827b067..dae25f2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -258,6 +258,31 @@ intellijPlatformTesting { } } + // Custom task for reverting unwanted import-hunks via a Koog agent. + // Reads a hunks JSON file (repo_root + list of import_reorder / + // wildcard_import_removal hunks) and runs an agent that surgically + // reverts each hunk in the corresponding Java source file. + // Usage: ./gradlew agentFixHunks -Pinput=/abs/path/to/hunks.json + // [-PbatchSize=5] [-PmaxAgentIterations=60] + register("agentFixHunks") { + task { + args(listOf("agent-fix-hunks")) + + val inputFile = project.findProperty("input") as? String ?: "" + val batchSize = project.findProperty("batchSize") as? String ?: "" + val maxAgentIterations = project.findProperty("maxAgentIterations") as? String ?: "" + + jvmArgs( + "-Xmx4G", + "-Djava.awt.headless=true", + "--add-exports", "java.base/jdk.internal.vm=ALL-UNNAMED", + "-Dfix.inputFile=${inputFile}", + "-Dfix.batchSize=${batchSize}", + "-Dfix.maxAgentIterations=${maxAgentIterations}", + ) + } + } + // Custom task for paraphrasing benchmark-record texts (semantic-preserving rewrite). // Reads a benchmark-record JSON file, paraphrases each {title, body} block, // and writes a same-schema JSON file. diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt new file mode 100644 index 0000000..058ed1e --- /dev/null +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt @@ -0,0 +1,181 @@ +package com.github.pderakhshanfar.codecocoonplugin.suggestions.impl + +import ai.koog.agents.core.agent.AIAgent +import ai.koog.agents.core.agent.config.AIAgentConfig +import ai.koog.agents.core.tools.ToolRegistry +import ai.koog.agents.ext.agent.reActStrategy +import ai.koog.agents.ext.tool.file.EditFileTool +import ai.koog.agents.ext.tool.file.ListDirectoryTool +import ai.koog.agents.ext.tool.file.ReadFileTool +import ai.koog.agents.features.eventHandler.feature.handleEvents +import ai.koog.prompt.dsl.Prompt +import ai.koog.prompt.llm.LLModel +import ai.koog.rag.base.files.JVMFileSystemProvider +import com.github.pderakhshanfar.codecocoonplugin.common.LLM +import com.intellij.openapi.diagnostic.thisLogger +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class FixHunksInput( + @SerialName("repo_root") val repoRoot: String, + @SerialName("patch_label") val patchLabel: String = "", + val description: String = "", + val hunks: List, +) + +@Serializable +data class HunkSpec( + val file: String, + @SerialName("hunk_type") val hunkType: String, + val description: String = "", + @SerialName("hunk_header") val hunkHeader: String = "", + @SerialName("old_start_line") val oldStartLine: Int = 0, + @SerialName("old_line_count") val oldLineCount: Int = 0, + @SerialName("new_start_line") val newStartLine: Int = 0, + @SerialName("new_line_count") val newLineCount: Int = 0, + val action: String = "", + @SerialName("full_hunk_diff") val fullHunkDiff: String = "", + @SerialName("original_order") val originalOrder: List? = null, + @SerialName("reordered_to") val reorderedTo: List? = null, + @SerialName("removed_wildcards") val removedWildcards: List? = null, +) + +private val agentJson = Json { + prettyPrint = true + encodeDefaults = false + explicitNulls = false +} + +suspend fun runImportHunkFixerAgent( + token: String, + model: LLModel, + repoRoot: String, + batchDescription: String, + hunks: List, + maxAgentIterations: Int = 60, +): Result { + val executor = LLM.createGrazieExecutor(token) + + val agent = AIAgent( + promptExecutor = executor, + agentConfig = AIAgentConfig( + prompt = Prompt.build("import-hunk-reverter") { + system(buildSystemPrompt(repoRoot)) + }, + model = model, + maxAgentIterations = maxAgentIterations, + ), + toolRegistry = ToolRegistry { + tool(ListDirectoryTool(JVMFileSystemProvider.ReadOnly)) + tool(ReadFileTool(JVMFileSystemProvider.ReadOnly)) + tool(EditFileTool(JVMFileSystemProvider.ReadWrite)) + }, + strategy = reActStrategy( + name = "import_hunk_reverter", + reasoningInterval = 1, + ), + ) { + handleEvents { + onToolCallStarting { ctx -> + thisLogger().info("→ Calling `${ctx.tool.name}` with: ${ctx.toolArgs}") + } + } + } + + return runCatching { + agent.run(agentInput = buildUserPrompt(repoRoot, batchDescription, hunks)) + } +} + +private fun buildSystemPrompt(repoRoot: String): String = """ + You are a precise code-editing agent. Your ONLY job is to revert specific + unwanted modifications that an automated refactoring tool introduced into + Java source files. You must minimize the diff: revert ONLY what each hunk + describes, and nothing else. + + REPO ROOT: $repoRoot + + ALLOWED CHANGES (per hunk): + • hunk_type = "import_reorder": + Reorder the import statements inside the file's import block so they + appear in the SAME ORDER as `original_order`. Do NOT add, remove, or + rewrite any import line — only reorder lines that are already there. + The set of import lines AFTER your edit must equal the set BEFORE. + • hunk_type = "wildcard_import_removal": + Restore the wildcard import lines from `removed_wildcards`, and + delete the consecutive single-class imports that those wildcards + subsume (these are the lines marked '+' in `full_hunk_diff` whose + package matches a restored wildcard's package, e.g. lines starting + with `import a.b.X;` for a restored `import a.b.*;`). + + FORBIDDEN CHANGES — MUST NEVER HAPPEN: + • Do not modify code outside the import block. + • Do not add, delete, rename, or reformat classes, methods, fields, or + comments. + • Do not change whitespace outside the lines you reorder/replace. + • Do not run any git command. Do not stage, commit, push, or branch. + • Do not invoke any tool other than list_directory, read_file, edit_file. + • Do not edit files that are not listed in the user prompt. + + PROCESS (apply per hunk): + 1. Resolve the file: it is at "$repoRoot/". If read_file fails + there, use list_directory under the repo root to locate it. + 2. Read the file. Locate the import block near `new_start_line` + (lines are 1-indexed; the block may have shifted by a few lines). + 3. Compute the minimal edit: + - For import_reorder: build the replacement block by taking the + CURRENT import lines (with their existing whitespace) and + reordering them to match `original_order`. The set of lines + must be IDENTICAL — only their order changes. + - For wildcard_import_removal: form the replacement by inserting + the wildcard lines from `removed_wildcards` (in the order they + appear in `original_order` if provided, otherwise as given) and + removing each single-class import they subsume. + 4. Use edit_file with `original` = the exact current import block + snippet (multiple consecutive lines, copied verbatim from the + file you just read) and `replacement` = the corrected block. Keep + a one- or two-line anchor of unchanged context (the package line + above and a blank line below) byte-for-byte identical inside both + `original` and `replacement` so that the edit is unambiguous and + proves you changed nothing else. + 5. After editing, read the file back and verify that ONLY import-block + lines differ from your mental model of the pre-edit content. If + anything else changed, fix it with another edit_file call. + + OUTPUT (final assistant message): + A short JSON report: + ```json + { + "results": [ + { "file": "", "hunk_type": "...", "applied": true, "reason": "..." } + ] + } + ``` +""".trimIndent() + +private fun buildUserPrompt( + repoRoot: String, + batchDescription: String, + hunks: List, +): String { + val hunksJson = agentJson.encodeToString(kotlinx.serialization.builtins.ListSerializer(HunkSpec.serializer()), hunks) + return """ + BATCH DESCRIPTION: $batchDescription + REPO ROOT: $repoRoot + + HUNKS TO REVERT (apply each one, in order): + ```json + $hunksJson + ``` + + For each hunk: + • Resolve the file as $repoRoot/. + • Read it, find the import block, and apply the minimal edit. + • Use list_directory only if read_file cannot find the file. + + When done with all hunks in this batch, emit the JSON report described + in the system prompt and stop. + """.trimIndent() +} diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt new file mode 100644 index 0000000..19c5c7a --- /dev/null +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt @@ -0,0 +1,126 @@ +package com.github.pderakhshanfar.codecocoonplugin.appstarter + +import ai.koog.prompt.executor.clients.openai.OpenAIModels +import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout +import com.github.pderakhshanfar.codecocoonplugin.suggestions.impl.FixHunksInput +import com.github.pderakhshanfar.codecocoonplugin.suggestions.impl.runImportHunkFixerAgent +import com.intellij.openapi.application.ApplicationStarter +import com.intellij.openapi.diagnostic.thisLogger +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import java.io.File +import kotlin.system.exitProcess + +/** + * Application starter for reverting unwanted import-hunks via a Koog agent. + * + * Reads a JSON file describing import_reorder / wildcard_import_removal hunks + * inside a Java repo and runs an agent that surgically reverts each hunk in + * the corresponding source file. Hunks are processed in batches to keep each + * agent invocation focused. + * + * Entry point when the IDE is launched with the 'agent-fix-hunks' command. + */ +class FixHunksStarter : ApplicationStarter { + override val requiredModality: Int = ApplicationStarter.NOT_IN_EDT + private val logger = thisLogger().withStdout() + + @OptIn(ExperimentalSerializationApi::class) + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + override fun main(args: List) { + val inputFile = System.getProperty("fix.inputFile") ?: "" + val batchSize = System.getProperty("fix.batchSize")?.toIntOrNull()?.takeIf { it > 0 } ?: DEFAULT_BATCH_SIZE + val maxAgentIterations = System.getProperty("fix.maxAgentIterations")?.toIntOrNull()?.takeIf { it > 0 } + ?: DEFAULT_MAX_AGENT_ITERATIONS + + if (inputFile.isEmpty()) { + logger.error("[FixHunks] Missing required parameter: -Pinput=") + exitProcess(1) + } + + val token = System.getenv("GRAZIE_TOKEN") + if (token == null) { + logger.error("[FixHunks] GRAZIE_TOKEN environment variable not set") + exitProcess(1) + } + + val inputJsonText = try { + File(inputFile).readText() + } catch (e: Exception) { + logger.error("[FixHunks] Cannot read input file '$inputFile': ${e.message}", e) + exitProcess(1) + } + + val input = try { + json.decodeFromString(FixHunksInput.serializer(), inputJsonText) + } catch (e: Exception) { + logger.error("[FixHunks] Failed to parse input JSON '$inputFile': ${e.message}", e) + exitProcess(1) + } + + val repoRoot = File(input.repoRoot).absoluteFile + if (!repoRoot.isDirectory) { + logger.error("[FixHunks] repo_root does not exist or is not a directory: ${input.repoRoot}") + exitProcess(1) + } + + if (input.hunks.isEmpty()) { + logger.warn("[FixHunks] No hunks to revert; exiting successfully") + exitProcess(0) + } + + val batches = input.hunks.chunked(batchSize) + logger.info("[FixHunks] Reverting ${input.hunks.size} hunks across ${batches.size} batch(es) of <= $batchSize") + logger.info("[FixHunks] Repo root: ${repoRoot.absolutePath}") + logger.info("[FixHunks] Description: ${input.description}") + + var failedBatches = 0 + runBlocking { + for ((index, batch) in batches.withIndex()) { + val batchNum = index + 1 + val fileCount = batch.map { it.file }.distinct().size + logger.info("[FixHunks] Batch $batchNum/${batches.size}: ${batch.size} hunks across $fileCount file(s)") + try { + val result = runImportHunkFixerAgent( + token = token, + model = OpenAIModels.Chat.GPT5Mini, + repoRoot = repoRoot.absolutePath, + batchDescription = input.description, + hunks = batch, + maxAgentIterations = maxAgentIterations, + ) + result.fold( + onSuccess = { agentReport -> + logger.info("[FixHunks] Batch $batchNum/${batches.size} done") + logger.info("[FixHunks] Agent report:\n$agentReport") + }, + onFailure = { e -> + failedBatches++ + logger.error("[FixHunks] Batch $batchNum/${batches.size} FAILED: ${e.message}", e) + }, + ) + } catch (e: Exception) { + failedBatches++ + logger.error("[FixHunks] Batch $batchNum/${batches.size} threw: ${e.message}", e) + } + } + } + + if (failedBatches > 0) { + logger.error("[FixHunks] Completed with $failedBatches failed batch(es) out of ${batches.size}") + exitProcess(1) + } + logger.info("[FixHunks] SUCCESS: all ${batches.size} batches applied") + exitProcess(0) + } + + companion object { + private const val DEFAULT_BATCH_SIZE = 5 + private const val DEFAULT_MAX_AGENT_ITERATIONS = 60 + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 2b798c1..10a46d6 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -17,5 +17,7 @@ id="transform-texts"/> + From b834e83456b6b1cab0c6b4cf130f2a3ce1d1a5bf Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Tue, 5 May 2026 01:14:38 +0200 Subject: [PATCH 49/67] feat: update fix hunk agent defaults --- .../codecocoonplugin/appstarter/FixHunksStarter.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt index 19c5c7a..ecbbe96 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt @@ -120,7 +120,7 @@ class FixHunksStarter : ApplicationStarter { } companion object { - private const val DEFAULT_BATCH_SIZE = 5 - private const val DEFAULT_MAX_AGENT_ITERATIONS = 60 + private const val DEFAULT_BATCH_SIZE = 8 + private const val DEFAULT_MAX_AGENT_ITERATIONS = 70 } } From e397ddf75852a212c50e08ad638efa709d128c94 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Tue, 5 May 2026 01:14:54 +0200 Subject: [PATCH 50/67] refactor: clarify autonomy requirements and disallow confirmation prompts in `FixImportHunks` agent --- .../suggestions/impl/FixImportHunks.kt | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt index 058ed1e..7e02a9d 100644 --- a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt @@ -144,7 +144,19 @@ private fun buildSystemPrompt(repoRoot: String): String = """ lines differ from your mental model of the pre-edit content. If anything else changed, fix it with another edit_file call. - OUTPUT (final assistant message): + AUTONOMY (CRITICAL): + • You operate fully autonomously. There is NO human in the loop. The + caller cannot answer questions, approve plans, or reply "yes". + • NEVER ask "Shall I proceed?", "Should I continue?", "Ready to apply?", + or any other confirmation question. If you do, the run is marked + failed and the changes are discarded. + • Do NOT emit a plan/thoughts message and stop. Plans without applied + edits are worthless here. Execute every required edit_file call in + this same run, before producing your terminal message. + • The ONLY acceptable terminal message is the JSON report below, and + you may emit it ONLY AFTER every needed edit_file call has run. + + OUTPUT (final assistant message — emit ONLY after all edits are done): A short JSON report: ```json { @@ -172,10 +184,16 @@ private fun buildUserPrompt( For each hunk: • Resolve the file as $repoRoot/. - • Read it, find the import block, and apply the minimal edit. + • Read it, find the import block, and apply the minimal edit by + calling edit_file NOW, in this same run. • Use list_directory only if read_file cannot find the file. - When done with all hunks in this batch, emit the JSON report described - in the system prompt and stop. + Do NOT respond with a plan and stop. Do NOT ask for confirmation + ("Shall I proceed?", "Ready to apply?", etc.) — there is no human + to answer, and the run will fail. + + Only AFTER every required edit_file call has been executed for + every hunk in this batch, emit the JSON report described in the + system prompt and end the turn. """.trimIndent() } From 1184d89e88ded6d590acd895800903883825c93f Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Tue, 5 May 2026 01:30:01 +0200 Subject: [PATCH 51/67] feat: update `HunkSpec` definition and prompting --- .../suggestions/impl/FixImportHunks.kt | 127 +++++++++++++----- .../appstarter/FixHunksStarter.kt | 3 + 2 files changed, 99 insertions(+), 31 deletions(-) diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt index 7e02a9d..361f5ee 100644 --- a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt @@ -25,6 +25,12 @@ data class FixHunksInput( val hunks: List, ) +@Serializable +data class ImportLine( + val line: Int, + @SerialName("import") val importStmt: String, +) + @Serializable data class HunkSpec( val file: String, @@ -37,8 +43,10 @@ data class HunkSpec( @SerialName("new_line_count") val newLineCount: Int = 0, val action: String = "", @SerialName("full_hunk_diff") val fullHunkDiff: String = "", - @SerialName("original_order") val originalOrder: List? = null, - @SerialName("reordered_to") val reorderedTo: List? = null, + // Present only when hunk_type == "import_reorder": + @SerialName("original_import_block") val originalImportBlock: List? = null, + @SerialName("current_import_block") val currentImportBlock: List? = null, + // Present only when hunk_type == "wildcard_import_removal": @SerialName("removed_wildcards") val removedWildcards: List? = null, ) @@ -91,30 +99,71 @@ suspend fun runImportHunkFixerAgent( private fun buildSystemPrompt(repoRoot: String): String = """ You are a precise code-editing agent. Your ONLY job is to revert specific - unwanted modifications that an automated refactoring tool introduced into - Java source files. You must minimize the diff: revert ONLY what each hunk - describes, and nothing else. + unwanted modifications that an automated refactoring tool (IntelliJ's + import optimizer) introduced into Java source files. You must minimize + the diff: revert ONLY what each hunk describes, and nothing else. REPO ROOT: $repoRoot + INPUT SCHEMA — fields you will receive per hunk: + Common to every hunk: + • file — repo-relative path to the Java source file. + • hunk_type — "import_reorder" or "wildcard_import_removal". + • description — human-readable explanation of this specific hunk. + • hunk_header — the raw `@@ -a,b +c,d @@` header from the diff. + • old_start_line / old_line_count — window in the ORIGINAL file. + • new_start_line / new_line_count — window in the CURRENT file + (where to look NOW). + • action — one-sentence imperative instruction. + • full_hunk_diff — the complete unified-diff hunk (context). + For hunk_type == "import_reorder" ONLY: + • original_import_block — list of {line, import} entries showing the + imports in the order they had BEFORE the + optimizer ran (the desired post-revert + order). The "line" field is the absolute + 1-indexed line number in the original file. + • current_import_block — list of {line, import} entries showing the + SAME imports as they appear NOW in the + file. The set of `import` strings is + identical to original_import_block — only + the ORDER differs. + For hunk_type == "wildcard_import_removal" ONLY: + • removed_wildcards — list of wildcard import statements (verbatim, + including `import` keyword and trailing `;`) + that the optimizer DELETED and that you must + ADD BACK. + ALLOWED CHANGES (per hunk): • hunk_type = "import_reorder": - Reorder the import statements inside the file's import block so they - appear in the SAME ORDER as `original_order`. Do NOT add, remove, or - rewrite any import line — only reorder lines that are already there. - The set of import lines AFTER your edit must equal the set BEFORE. + Reorder the lines in the file's import block so that the imports + appear in the SAME ORDER as `original_import_block` (top → bottom). + The SET of import lines AFTER your edit MUST equal the SET BEFORE + (same imports, just reordered). Preserve any blank lines that + separate sub-groups in the current block exactly. • hunk_type = "wildcard_import_removal": - Restore the wildcard import lines from `removed_wildcards`, and - delete the consecutive single-class imports that those wildcards - subsume (these are the lines marked '+' in `full_hunk_diff` whose - package matches a restored wildcard's package, e.g. lines starting - with `import a.b.X;` for a restored `import a.b.*;`). + Add each line in `removed_wildcards` back into the file's import + section. Do NOT remove or modify any other import. Place the + restored wildcard at the location implied by `full_hunk_diff` and + `new_start_line` (typically the same relative position it had in + the original — e.g. as a separate static-imports group at the end + of the import block, with a preceding blank line if the diff + shows one). If the diff is ambiguous, prefer placing it at the + end of the import block. + + DO NOT TOUCH OTHER CHANGES IN `full_hunk_diff`: + The `full_hunk_diff` may contain unrelated `+`/`-` lines from other + transformations (e.g. a class rename like `JSON` → `JsonMapper`). + Those are intentional and must STAY. Restrict your edit strictly to + the field that describes your hunk type: + - For import_reorder: only the imports in original_import_block / + current_import_block. + - For wildcard_import_removal: only the lines in removed_wildcards. FORBIDDEN CHANGES — MUST NEVER HAPPEN: • Do not modify code outside the import block. - • Do not add, delete, rename, or reformat classes, methods, fields, or - comments. - • Do not change whitespace outside the lines you reorder/replace. + • Do not add, delete, rename, or reformat classes, methods, fields, + comments, javadoc, or annotations. + • Do not change whitespace outside the lines you reorder/insert. • Do not run any git command. Do not stage, commit, push, or branch. • Do not invoke any tool other than list_directory, read_file, edit_file. • Do not edit files that are not listed in the user prompt. @@ -125,21 +174,27 @@ private fun buildSystemPrompt(repoRoot: String): String = """ 2. Read the file. Locate the import block near `new_start_line` (lines are 1-indexed; the block may have shifted by a few lines). 3. Compute the minimal edit: - - For import_reorder: build the replacement block by taking the - CURRENT import lines (with their existing whitespace) and - reordering them to match `original_order`. The set of lines - must be IDENTICAL — only their order changes. - - For wildcard_import_removal: form the replacement by inserting - the wildcard lines from `removed_wildcards` (in the order they - appear in `original_order` if provided, otherwise as given) and - removing each single-class import they subsume. + - For import_reorder: + • Confirm that the set of imports currently present matches + `current_import_block`. (If not, the file has drifted — + still attempt the reorder using whatever imports are there + that also appear in `original_import_block`.) + • Build the replacement block by listing the imports in the + order of `original_import_block`. Preserve any blank + separator lines you observed in the current block at the + SAME positions (e.g., a blank line before the static + imports section). + - For wildcard_import_removal: + • Insert each wildcard from `removed_wildcards` into the + current import block at the right position. Do NOT remove + any other import line. 4. Use edit_file with `original` = the exact current import block snippet (multiple consecutive lines, copied verbatim from the file you just read) and `replacement` = the corrected block. Keep a one- or two-line anchor of unchanged context (the package line - above and a blank line below) byte-for-byte identical inside both - `original` and `replacement` so that the edit is unambiguous and - proves you changed nothing else. + above and the blank line below the import block) byte-for-byte + identical inside both `original` and `replacement` so the edit is + unambiguous and proves you changed nothing else. 5. After editing, read the file back and verify that ONLY import-block lines differ from your mental model of the pre-edit content. If anything else changed, fix it with another edit_file call. @@ -172,7 +227,10 @@ private fun buildUserPrompt( batchDescription: String, hunks: List, ): String { - val hunksJson = agentJson.encodeToString(kotlinx.serialization.builtins.ListSerializer(HunkSpec.serializer()), hunks) + val hunksJson = agentJson.encodeToString( + kotlinx.serialization.builtins.ListSerializer(HunkSpec.serializer()), + hunks, + ) return """ BATCH DESCRIPTION: $batchDescription REPO ROOT: $repoRoot @@ -184,9 +242,16 @@ private fun buildUserPrompt( For each hunk: • Resolve the file as $repoRoot/. - • Read it, find the import block, and apply the minimal edit by - calling edit_file NOW, in this same run. + • Read it, find the import block near `new_start_line`, and apply + the minimal edit by calling edit_file NOW, in this same run. + • For "import_reorder": reorder current imports to match + `original_import_block` (set of imports unchanged). + • For "wildcard_import_removal": insert every line from + `removed_wildcards` into the import block; do not remove or + modify any other import. • Use list_directory only if read_file cannot find the file. + • Ignore unrelated `+`/`-` lines in `full_hunk_diff` — they are + from other transformations and must stay. Do NOT respond with a plan and stop. Do NOT ask for confirmation ("Shall I proceed?", "Ready to apply?", etc.) — there is no human diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt index ecbbe96..16924a8 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt @@ -75,8 +75,11 @@ class FixHunksStarter : ApplicationStarter { } val batches = input.hunks.chunked(batchSize) + val typeCounts = input.hunks.groupingBy { it.hunkType }.eachCount() logger.info("[FixHunks] Reverting ${input.hunks.size} hunks across ${batches.size} batch(es) of <= $batchSize") logger.info("[FixHunks] Repo root: ${repoRoot.absolutePath}") + logger.info("[FixHunks] Patch label: ${input.patchLabel.ifEmpty { "" }}") + logger.info("[FixHunks] Hunk types: $typeCounts") logger.info("[FixHunks] Description: ${input.description}") var failedBatches = 0 From c8c32b38aefe28f48ba9659a308f2d45d1f12aa8 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Tue, 5 May 2026 01:30:28 +0200 Subject: [PATCH 52/67] fix: annotate `FixImportHunks` with `ExperimentalSerializationApi` usage --- .../codecocoonplugin/suggestions/impl/FixImportHunks.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt index 361f5ee..8128dd7 100644 --- a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt @@ -13,6 +13,7 @@ import ai.koog.prompt.llm.LLModel import ai.koog.rag.base.files.JVMFileSystemProvider import com.github.pderakhshanfar.codecocoonplugin.common.LLM import com.intellij.openapi.diagnostic.thisLogger +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -50,6 +51,7 @@ data class HunkSpec( @SerialName("removed_wildcards") val removedWildcards: List? = null, ) +@OptIn(ExperimentalSerializationApi::class) private val agentJson = Json { prettyPrint = true encodeDefaults = false From 3f478d1c9b114410bb89d167e35d10591b4cc730 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Tue, 5 May 2026 02:26:09 +0200 Subject: [PATCH 53/67] feat: add filesystem verification and enhanced logging for `FixImportHunks` --- .../suggestions/impl/FixImportHunks.kt | 153 +++++++++++++++++- 1 file changed, 150 insertions(+), 3 deletions(-) diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt index 8128dd7..cb8bbda 100644 --- a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt @@ -12,11 +12,13 @@ import ai.koog.prompt.dsl.Prompt import ai.koog.prompt.llm.LLModel import ai.koog.rag.base.files.JVMFileSystemProvider import com.github.pderakhshanfar.codecocoonplugin.common.LLM +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.thisLogger import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import java.io.File @Serializable data class FixHunksInput( @@ -89,13 +91,140 @@ suspend fun runImportHunkFixerAgent( ) { handleEvents { onToolCallStarting { ctx -> - thisLogger().info("→ Calling `${ctx.tool.name}` with: ${ctx.toolArgs}") + logBoth("→ Calling `${ctx.tool.name}` with: ${ctx.toolArgs}") + } + onToolCallCompleted { ctx -> + logBoth("← `${ctx.tool.name}` returned: ${ctx.result.toString().take(MAX_TOOL_LOG_CHARS)}") + } + onToolCallFailed { ctx -> + logBoth("✖ `${ctx.tool.name}` FAILED: ${ctx.throwable.message}") + } + onToolValidationFailed { ctx -> + logBoth("✖ `${ctx.tool.name}` VALIDATION FAILED: ${ctx.error}") } } } - return runCatching { + val agentReport = runCatching { agent.run(agentInput = buildUserPrompt(repoRoot, batchDescription, hunks)) + }.getOrElse { + return Result.failure(it) + } + + val verifications = hunks.map { verifyHunkApplied(repoRoot, it) } + val unapplied = verifications.filterNot { it.applied } + return if (unapplied.isEmpty()) { + Result.success(agentReport) + } else { + val summary = unapplied.joinToString("\n - ") { "${it.file} [${it.hunkType}]: ${it.reason}" } + Result.failure( + IllegalStateException( + "Filesystem verification rejected the agent's report. " + + "${unapplied.size}/${hunks.size} hunk(s) NOT applied:\n - $summary\n" + + "Agent's own report (advisory):\n$agentReport" + ) + ) + } +} + +private const val MAX_TOOL_LOG_CHARS = 500 + +private val agentLogger = Logger.getInstance("FixImportHunksAgent") + +private fun logBoth(message: String) { + agentLogger.info(message) + println(message) +} + +/** + * Result of filesystem-level verification of a single hunk after the agent run. + * The agent's own `applied: true` claim is advisory; this is the source of truth. + */ +data class VerifyResult( + val file: String, + val hunkType: String, + val applied: Boolean, + val reason: String, +) + +/** + * Reads the file under `repoRoot/` and checks the post-condition + * implied by the hunk type. + * + * - `import_reorder`: filters the file's imports down to the ones listed in + * `originalImportBlock` (preserving file order) and checks they match the + * target order. If they still match `currentImportBlock`, the agent did + * nothing. + * - `wildcard_import_removal`: every line in `removedWildcards` must appear + * verbatim as a standalone import line in the file. + */ +private fun verifyHunkApplied(repoRoot: String, hunk: HunkSpec): VerifyResult { + val target = File(repoRoot, hunk.file) + if (!target.isFile) { + return VerifyResult(hunk.file, hunk.hunkType, false, "file not found at $target") + } + val content = try { + target.readText() + } catch (e: Exception) { + return VerifyResult(hunk.file, hunk.hunkType, false, "could not read file: ${e.message}") + } + val fileImports = content.lineSequence() + .map { it.trim() } + .filter { it.startsWith("import ") && it.endsWith(";") } + .toList() + + return when (hunk.hunkType) { + "import_reorder" -> verifyReorder(hunk, fileImports) + "wildcard_import_removal" -> verifyWildcard(hunk, fileImports) + else -> VerifyResult(hunk.file, hunk.hunkType, false, "unknown hunk_type: ${hunk.hunkType}") + } +} + +private fun verifyReorder(hunk: HunkSpec, fileImports: List): VerifyResult { + val target = hunk.originalImportBlock?.map { it.importStmt.trim() } + ?: return VerifyResult(hunk.file, hunk.hunkType, false, "missing originalImportBlock") + val current = hunk.currentImportBlock?.map { it.importStmt.trim() } ?: emptyList() + + // Are all expected imports even present? + val missing = target.filter { it !in fileImports } + if (missing.isNotEmpty()) { + return VerifyResult( + hunk.file, hunk.hunkType, false, + "imports missing from file: ${missing.joinToString(", ")}", + ) + } + + // Filter file's imports down to just the ones this hunk cares about, + // preserving file order. That sequence must equal the target order. + val targetSet = target.toSet() + val filtered = fileImports.filter { it in targetSet } + + return if (filtered == target) { + VerifyResult(hunk.file, hunk.hunkType, true, "imports in target order") + } else if (current.isNotEmpty() && filtered == current) { + VerifyResult( + hunk.file, hunk.hunkType, false, + "imports still in pre-revert order; agent did not edit the file", + ) + } else { + VerifyResult( + hunk.file, hunk.hunkType, false, + "imports in unexpected order: $filtered (expected $target)", + ) + } +} + +private fun verifyWildcard(hunk: HunkSpec, fileImports: List): VerifyResult { + val expected = hunk.removedWildcards?.map { it.trim() } + ?: return VerifyResult(hunk.file, hunk.hunkType, false, "missing removedWildcards") + val missing = expected.filter { it !in fileImports } + return if (missing.isEmpty()) { + VerifyResult(hunk.file, hunk.hunkType, true, "wildcards present: ${expected.joinToString(", ")}") + } else { + VerifyResult( + hunk.file, hunk.hunkType, false, + "wildcards still missing from file: ${missing.joinToString(", ")}", + ) } } @@ -105,7 +234,7 @@ private fun buildSystemPrompt(repoRoot: String): String = """ import optimizer) introduced into Java source files. You must minimize the diff: revert ONLY what each hunk describes, and nothing else. - REPO ROOT: $repoRoot + REPO ROOT: '$repoRoot' INPUT SCHEMA — fields you will receive per hunk: Common to every hunk: @@ -213,6 +342,24 @@ private fun buildSystemPrompt(repoRoot: String): String = """ • The ONLY acceptable terminal message is the JSON report below, and you may emit it ONLY AFTER every needed edit_file call has run. + TRUTHFULNESS (CRITICAL): + • Failure to call `edit_file` for a hunk is NOT a no-op success — it + is a FAILURE of the run. Skipping a hunk because "the file already + looks fine" is forbidden; trust the input, not your judgment. + • Mark a hunk `"applied": true` ONLY if you actually called + `edit_file` for that hunk AND the tool's response said the patch + was applied successfully ("Successfully edited file"). If the tool + responded with "patch application failed", the hunk is NOT applied + — re-read the file with `read_file` to copy the import block + verbatim, then call `edit_file` again with that fresh `original`. + • NEVER report `"applied": true` for a hunk you did not edit. NEVER + report success based on what you intended to do — only on tool + responses you actually received in this run. + • A separate filesystem verifier runs after you finish and will + compare every file against the expected post-revert state. False + `"applied": true` claims will be caught and the run will be marked + failed regardless of what your report says. + OUTPUT (final assistant message — emit ONLY after all edits are done): A short JSON report: ```json From 48af1e4c6e2272c9b56726862270fbdfd4fcaee0 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Tue, 5 May 2026 02:53:37 +0200 Subject: [PATCH 54/67] feat: add support for verifying and reverting `import_cross_hunk_move` transformations in `FixImportHunks` --- .../suggestions/impl/FixImportHunks.kt | 191 +++++++++++++++++- 1 file changed, 181 insertions(+), 10 deletions(-) diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt index cb8bbda..f681fed 100644 --- a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt @@ -50,7 +50,13 @@ data class HunkSpec( @SerialName("original_import_block") val originalImportBlock: List? = null, @SerialName("current_import_block") val currentImportBlock: List? = null, // Present only when hunk_type == "wildcard_import_removal": + // Ordered list; an empty string "" denotes a blank separator line that was removed and must be restored. @SerialName("removed_wildcards") val removedWildcards: List? = null, + // Present only when hunk_type == "import_cross_hunk_move": + // Role of THIS hunk in a multi-hunk rotation: "spurious_addition" | "missing_import" | "mixed". + @SerialName("cross_move_role") val crossMoveRole: String? = null, + // Specific import line(s) involved in THIS hunk of the rotation. + @SerialName("moved_imports") val movedImports: List? = null, ) @OptIn(ExperimentalSerializationApi::class) @@ -105,13 +111,24 @@ suspend fun runImportHunkFixerAgent( } } + // Snapshot files touched by import_cross_hunk_move BEFORE the run, so + // we can detect "agent did nothing" cases that aren't visible from + // import-presence checks alone (the rotation preserves the import set). + val crossMoveFiles = hunks + .filter { it.hunkType == "import_cross_hunk_move" } + .map { it.file } + .distinct() + val crossMoveBefore: Map = crossMoveFiles.mapNotNull { rel -> + runCatching { rel to File(repoRoot, rel).readText() }.getOrNull() + }.toMap() + val agentReport = runCatching { agent.run(agentInput = buildUserPrompt(repoRoot, batchDescription, hunks)) }.getOrElse { return Result.failure(it) } - val verifications = hunks.map { verifyHunkApplied(repoRoot, it) } + val verifications = hunks.map { verifyHunkApplied(repoRoot, it, crossMoveBefore) } val unapplied = verifications.filterNot { it.applied } return if (unapplied.isEmpty()) { Result.success(agentReport) @@ -158,7 +175,11 @@ data class VerifyResult( * - `wildcard_import_removal`: every line in `removedWildcards` must appear * verbatim as a standalone import line in the file. */ -private fun verifyHunkApplied(repoRoot: String, hunk: HunkSpec): VerifyResult { +private fun verifyHunkApplied( + repoRoot: String, + hunk: HunkSpec, + crossMoveBefore: Map = emptyMap(), +): VerifyResult { val target = File(repoRoot, hunk.file) if (!target.isFile) { return VerifyResult(hunk.file, hunk.hunkType, false, "file not found at $target") @@ -176,6 +197,7 @@ private fun verifyHunkApplied(repoRoot: String, hunk: HunkSpec): VerifyResult { return when (hunk.hunkType) { "import_reorder" -> verifyReorder(hunk, fileImports) "wildcard_import_removal" -> verifyWildcard(hunk, fileImports) + "import_cross_hunk_move" -> verifyCrossHunkMove(hunk, fileImports, content, crossMoveBefore[hunk.file]) else -> VerifyResult(hunk.file, hunk.hunkType, false, "unknown hunk_type: ${hunk.hunkType}") } } @@ -215,7 +237,9 @@ private fun verifyReorder(hunk: HunkSpec, fileImports: List): VerifyResu } private fun verifyWildcard(hunk: HunkSpec, fileImports: List): VerifyResult { - val expected = hunk.removedWildcards?.map { it.trim() } + val expected = hunk.removedWildcards + ?.filter { it.isNotEmpty() } // skip blank-separator markers + ?.map { it.trim() } ?: return VerifyResult(hunk.file, hunk.hunkType, false, "missing removedWildcards") val missing = expected.filter { it !in fileImports } return if (missing.isEmpty()) { @@ -228,6 +252,87 @@ private fun verifyWildcard(hunk: HunkSpec, fileImports: List): VerifyRes } } +/** + * Verifies an `import_cross_hunk_move` hunk. The rotation preserves the + * file's overall import set, so set-presence alone is insufficient — we + * also compare the post-run file content against a pre-run snapshot to + * detect "agent did nothing" cases. + * + * - "missing_import" / "mixed": every line in `moved_imports` MUST be + * present in the file's imports after the run. + * - "spurious_addition" / "mixed": the file content must DIFFER from the + * pre-run snapshot (something was edited). We can't easily check + * "imports no longer at the spurious location" without parsing line + * ranges, so we rely on the file-changed signal plus the missing-import + * counterpart hunks (each rotation has at least one missing_import or + * mixed entry as well). + */ +private fun verifyCrossHunkMove( + hunk: HunkSpec, + fileImports: List, + fileContent: String, + beforeContent: String?, +): VerifyResult { + val moved = hunk.movedImports?.map { it.trim() } + ?: return VerifyResult(hunk.file, hunk.hunkType, false, "missing movedImports") + val role = hunk.crossMoveRole + ?: return VerifyResult(hunk.file, hunk.hunkType, false, "missing crossMoveRole") + + val fileChanged = beforeContent != null && fileContent != beforeContent + val snapshotMissing = beforeContent == null + + return when (role) { + "missing_import" -> { + val missing = moved.filter { it !in fileImports } + if (missing.isEmpty()) { + VerifyResult(hunk.file, hunk.hunkType, true, "moved imports restored: ${moved.joinToString(", ")}") + } else { + VerifyResult( + hunk.file, hunk.hunkType, false, + "moved imports still missing: ${missing.joinToString(", ")}", + ) + } + } + "spurious_addition" -> { + when { + snapshotMissing -> VerifyResult( + hunk.file, hunk.hunkType, true, + "no pre-run snapshot; spurious_addition cannot be falsified", + ) + fileChanged -> VerifyResult( + hunk.file, hunk.hunkType, true, + "file changed (rotation applied)", + ) + else -> VerifyResult( + hunk.file, hunk.hunkType, false, + "file unchanged since before agent run; rotation not applied", + ) + } + } + "mixed" -> { + val missing = moved.filter { it !in fileImports } + when { + missing.isNotEmpty() -> VerifyResult( + hunk.file, hunk.hunkType, false, + "moved imports missing from file: ${missing.joinToString(", ")}", + ) + snapshotMissing || fileChanged -> VerifyResult( + hunk.file, hunk.hunkType, true, + "moved imports present and file changed", + ) + else -> VerifyResult( + hunk.file, hunk.hunkType, false, + "file unchanged since before agent run; rotation not applied", + ) + } + } + else -> VerifyResult( + hunk.file, hunk.hunkType, false, + "unknown cross_move_role: $role", + ) + } +} + private fun buildSystemPrompt(repoRoot: String): String = """ You are a precise code-editing agent. Your ONLY job is to revert specific unwanted modifications that an automated refactoring tool (IntelliJ's @@ -239,7 +344,8 @@ private fun buildSystemPrompt(repoRoot: String): String = """ INPUT SCHEMA — fields you will receive per hunk: Common to every hunk: • file — repo-relative path to the Java source file. - • hunk_type — "import_reorder" or "wildcard_import_removal". + • hunk_type — "import_reorder", "wildcard_import_removal", + or "import_cross_hunk_move". • description — human-readable explanation of this specific hunk. • hunk_header — the raw `@@ -a,b +c,d @@` header from the diff. • old_start_line / old_line_count — window in the ORIGINAL file. @@ -259,10 +365,26 @@ private fun buildSystemPrompt(repoRoot: String): String = """ identical to original_import_block — only the ORDER differs. For hunk_type == "wildcard_import_removal" ONLY: - • removed_wildcards — list of wildcard import statements (verbatim, + • removed_wildcards — ORDERED list of import lines (verbatim, including `import` keyword and trailing `;`) that the optimizer DELETED and that you must - ADD BACK. + ADD BACK in this order. An entry equal to the + empty string "" denotes a BLANK separator + line that was also removed — restore it as a + blank line in the same position. + For hunk_type == "import_cross_hunk_move" ONLY: + • cross_move_role — role of THIS hunk in a coordinated multi-hunk + rotation: + "spurious_addition" — this location only + ADDS imports that actually belong at + another location → REMOVE them here. + "missing_import" — this location only + REMOVES imports that belong here → + ADD them back here. + "mixed" — this location both adds and + removes; revert both sides. + • moved_imports — the specific import line(s) involved in THIS + hunk (verbatim). ALLOWED CHANGES (per hunk): • hunk_type = "import_reorder": @@ -280,6 +402,36 @@ private fun buildSystemPrompt(repoRoot: String): String = """ of the import block, with a preceding blank line if the diff shows one). If the diff is ambiguous, prefer placing it at the end of the import block. + • hunk_type = "import_cross_hunk_move": + The optimizer ROTATED imports between two or more locations in + the SAME file (e.g. swapped imports that were originally near + line 3 with imports near line 120 of the same file). The input + contains MULTIPLE entries with this hunk_type for one file — + they are coordinated and must be reverted TOGETHER as a single + rotation, not independently: + - cross_move_role == "spurious_addition" → DELETE the lines + in `moved_imports` from this location. + - cross_move_role == "missing_import" → INSERT the lines in + `moved_imports` back at this location. + - cross_move_role == "mixed" → both delete and insert at this + location, as `action` describes. + Net effect: the SET of imports in the file is unchanged; only + their positions are restored. Process all entries for the same + file in ONE pass (one read_file, then plan all + insertions/deletions, then issue your edit_file call(s)). Do + NOT insert before deleting (or vice versa) in a way that ends + up duplicating the same import line. + + CROSS-HUNK COORDINATION (import_cross_hunk_move): + When you see N ≥ 2 import_cross_hunk_move entries with the same + `file`, treat them as a single coordinated revert. After your + edits: + • Every import that was originally in the file must still be + there exactly once (no duplicates, no losses). + • Imports listed in spurious_addition entries must NOT remain at + their spurious location. + • Imports listed in missing_import entries MUST appear at their + missing location. DO NOT TOUCH OTHER CHANGES IN `full_hunk_diff`: The `full_hunk_diff` may contain unrelated `+`/`-` lines from other @@ -289,6 +441,8 @@ private fun buildSystemPrompt(repoRoot: String): String = """ - For import_reorder: only the imports in original_import_block / current_import_block. - For wildcard_import_removal: only the lines in removed_wildcards. + - For import_cross_hunk_move: only the lines in moved_imports + (across all hunks for the same file). FORBIDDEN CHANGES — MUST NEVER HAPPEN: • Do not modify code outside the import block. @@ -317,8 +471,17 @@ private fun buildSystemPrompt(repoRoot: String): String = """ imports section). - For wildcard_import_removal: • Insert each wildcard from `removed_wildcards` into the - current import block at the right position. Do NOT remove - any other import line. + current import block at the right position. Empty-string + entries denote a blank separator line — restore them as + blank lines. Do NOT remove any other import line. + - For import_cross_hunk_move: + • Group all entries with this hunk_type for the SAME file + and revert them as ONE rotation. Read the file ONCE, + plan all deletions (spurious_addition entries) and + insertions (missing_import entries; both for mixed) + together, then issue your edit_file call(s). The + post-revert file must contain every original import + exactly once — no duplicates, no losses. 4. Use edit_file with `original` = the exact current import block snippet (multiple consecutive lines, copied verbatim from the file you just read) and `replacement` = the corrected block. Keep @@ -396,8 +559,16 @@ private fun buildUserPrompt( • For "import_reorder": reorder current imports to match `original_import_block` (set of imports unchanged). • For "wildcard_import_removal": insert every line from - `removed_wildcards` into the import block; do not remove or - modify any other import. + `removed_wildcards` (in order; "" entries = blank separator + lines) into the import block; do not remove or modify any + other import. + • For "import_cross_hunk_move": group ALL entries for the SAME + file and revert them as one rotation. Per-entry: if + cross_move_role == "spurious_addition", DELETE the lines in + `moved_imports` from this location; if "missing_import", + INSERT them back at this location; if "mixed", do both as the + `action` describes. The file's overall import set must remain + unchanged — no duplicates, no losses. • Use list_directory only if read_file cannot find the file. • Ignore unrelated `+`/`-` lines in `full_hunk_diff` — they are from other transformations and must stay. From b38b92377973de9c1011f0365c4d0000165fa220 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Tue, 5 May 2026 04:24:00 +0200 Subject: [PATCH 55/67] feat: add output file support and enhanced batch processing in `FixHunks` --- build.gradle.kts | 2 + .../suggestions/impl/FixImportHunks.kt | 92 ++++---- .../appstarter/FixHunksStarter.kt | 198 ++++++++++++++++-- 3 files changed, 243 insertions(+), 49 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index dae25f2..e045dee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -269,6 +269,7 @@ intellijPlatformTesting { args(listOf("agent-fix-hunks")) val inputFile = project.findProperty("input") as? String ?: "" + val outputFile = project.findProperty("output") as? String ?: "" val batchSize = project.findProperty("batchSize") as? String ?: "" val maxAgentIterations = project.findProperty("maxAgentIterations") as? String ?: "" @@ -277,6 +278,7 @@ intellijPlatformTesting { "-Djava.awt.headless=true", "--add-exports", "java.base/jdk.internal.vm=ALL-UNNAMED", "-Dfix.inputFile=${inputFile}", + "-Dfix.outputFile=${outputFile}", "-Dfix.batchSize=${batchSize}", "-Dfix.maxAgentIterations=${maxAgentIterations}", ) diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt index f681fed..3455d67 100644 --- a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt @@ -36,6 +36,7 @@ data class ImportLine( @Serializable data class HunkSpec( + val id: String = "", val file: String, @SerialName("hunk_type") val hunkType: String, val description: String = "", @@ -66,6 +67,23 @@ private val agentJson = Json { explicitNulls = false } +/** + * Outcome of a single agent batch invocation. + * + * @property agentReport the agent's terminal message (advisory; not source of truth) + * @property verifications per-hunk filesystem verification results, in the same order as the input hunks + * @property error null on success; an exception describing the agent-level failure (e.g. iteration cap, network) + * The verifications list is still the per-hunk source of truth even when this is non-null. + */ +data class FixBatchResult( + val agentReport: String?, + val verifications: List, + val error: Throwable? = null, +) { + val appliedHunkIds: List get() = verifications.filter { it.applied }.map { it.hunkId }.filter { it.isNotEmpty() } + val isFullySuccessful: Boolean get() = error == null && verifications.all { it.applied } +} + suspend fun runImportHunkFixerAgent( token: String, model: LLModel, @@ -73,7 +91,7 @@ suspend fun runImportHunkFixerAgent( batchDescription: String, hunks: List, maxAgentIterations: Int = 60, -): Result { +): FixBatchResult { val executor = LLM.createGrazieExecutor(token) val agent = AIAgent( @@ -122,25 +140,24 @@ suspend fun runImportHunkFixerAgent( runCatching { rel to File(repoRoot, rel).readText() }.getOrNull() }.toMap() - val agentReport = runCatching { + val agentRun = runCatching { agent.run(agentInput = buildUserPrompt(repoRoot, batchDescription, hunks)) - }.getOrElse { - return Result.failure(it) } + val agentReport = agentRun.getOrNull() + val agentError = agentRun.exceptionOrNull() + // Verify regardless of whether the agent threw — partial successes count. val verifications = hunks.map { verifyHunkApplied(repoRoot, it, crossMoveBefore) } + return FixBatchResult(agentReport = agentReport, verifications = verifications, error = agentError) +} + +/** Renders the unapplied hunks as a multi-line summary suitable for logging. */ +fun FixBatchResult.unappliedSummary(): String { val unapplied = verifications.filterNot { it.applied } - return if (unapplied.isEmpty()) { - Result.success(agentReport) - } else { - val summary = unapplied.joinToString("\n - ") { "${it.file} [${it.hunkType}]: ${it.reason}" } - Result.failure( - IllegalStateException( - "Filesystem verification rejected the agent's report. " + - "${unapplied.size}/${hunks.size} hunk(s) NOT applied:\n - $summary\n" + - "Agent's own report (advisory):\n$agentReport" - ) - ) + if (unapplied.isEmpty()) return "all hunks verified applied" + return unapplied.joinToString("\n - ", prefix = " - ") { + val tag = if (it.hunkId.isNotEmpty()) "${it.hunkId} " else "" + "$tag${it.file} [${it.hunkType}]: ${it.reason}" } } @@ -158,6 +175,7 @@ private fun logBoth(message: String) { * The agent's own `applied: true` claim is advisory; this is the source of truth. */ data class VerifyResult( + val hunkId: String, val file: String, val hunkType: String, val applied: Boolean, @@ -182,12 +200,12 @@ private fun verifyHunkApplied( ): VerifyResult { val target = File(repoRoot, hunk.file) if (!target.isFile) { - return VerifyResult(hunk.file, hunk.hunkType, false, "file not found at $target") + return VerifyResult(hunk.id, hunk.file, hunk.hunkType, false, "file not found at $target") } val content = try { target.readText() } catch (e: Exception) { - return VerifyResult(hunk.file, hunk.hunkType, false, "could not read file: ${e.message}") + return VerifyResult(hunk.id, hunk.file, hunk.hunkType, false, "could not read file: ${e.message}") } val fileImports = content.lineSequence() .map { it.trim() } @@ -198,20 +216,20 @@ private fun verifyHunkApplied( "import_reorder" -> verifyReorder(hunk, fileImports) "wildcard_import_removal" -> verifyWildcard(hunk, fileImports) "import_cross_hunk_move" -> verifyCrossHunkMove(hunk, fileImports, content, crossMoveBefore[hunk.file]) - else -> VerifyResult(hunk.file, hunk.hunkType, false, "unknown hunk_type: ${hunk.hunkType}") + else -> VerifyResult(hunk.id, hunk.file, hunk.hunkType, false, "unknown hunk_type: ${hunk.hunkType}") } } private fun verifyReorder(hunk: HunkSpec, fileImports: List): VerifyResult { val target = hunk.originalImportBlock?.map { it.importStmt.trim() } - ?: return VerifyResult(hunk.file, hunk.hunkType, false, "missing originalImportBlock") + ?: return VerifyResult(hunk.id, hunk.file, hunk.hunkType, false, "missing originalImportBlock") val current = hunk.currentImportBlock?.map { it.importStmt.trim() } ?: emptyList() // Are all expected imports even present? val missing = target.filter { it !in fileImports } if (missing.isNotEmpty()) { return VerifyResult( - hunk.file, hunk.hunkType, false, + hunk.id, hunk.file, hunk.hunkType, false, "imports missing from file: ${missing.joinToString(", ")}", ) } @@ -222,15 +240,15 @@ private fun verifyReorder(hunk: HunkSpec, fileImports: List): VerifyResu val filtered = fileImports.filter { it in targetSet } return if (filtered == target) { - VerifyResult(hunk.file, hunk.hunkType, true, "imports in target order") + VerifyResult(hunk.id, hunk.file, hunk.hunkType, true, "imports in target order") } else if (current.isNotEmpty() && filtered == current) { VerifyResult( - hunk.file, hunk.hunkType, false, + hunk.id, hunk.file, hunk.hunkType, false, "imports still in pre-revert order; agent did not edit the file", ) } else { VerifyResult( - hunk.file, hunk.hunkType, false, + hunk.id, hunk.file, hunk.hunkType, false, "imports in unexpected order: $filtered (expected $target)", ) } @@ -240,13 +258,13 @@ private fun verifyWildcard(hunk: HunkSpec, fileImports: List): VerifyRes val expected = hunk.removedWildcards ?.filter { it.isNotEmpty() } // skip blank-separator markers ?.map { it.trim() } - ?: return VerifyResult(hunk.file, hunk.hunkType, false, "missing removedWildcards") + ?: return VerifyResult(hunk.id, hunk.file, hunk.hunkType, false, "missing removedWildcards") val missing = expected.filter { it !in fileImports } return if (missing.isEmpty()) { - VerifyResult(hunk.file, hunk.hunkType, true, "wildcards present: ${expected.joinToString(", ")}") + VerifyResult(hunk.id, hunk.file, hunk.hunkType, true, "wildcards present: ${expected.joinToString(", ")}") } else { VerifyResult( - hunk.file, hunk.hunkType, false, + hunk.id, hunk.file, hunk.hunkType, false, "wildcards still missing from file: ${missing.joinToString(", ")}", ) } @@ -274,9 +292,9 @@ private fun verifyCrossHunkMove( beforeContent: String?, ): VerifyResult { val moved = hunk.movedImports?.map { it.trim() } - ?: return VerifyResult(hunk.file, hunk.hunkType, false, "missing movedImports") + ?: return VerifyResult(hunk.id, hunk.file, hunk.hunkType, false, "missing movedImports") val role = hunk.crossMoveRole - ?: return VerifyResult(hunk.file, hunk.hunkType, false, "missing crossMoveRole") + ?: return VerifyResult(hunk.id, hunk.file, hunk.hunkType, false, "missing crossMoveRole") val fileChanged = beforeContent != null && fileContent != beforeContent val snapshotMissing = beforeContent == null @@ -285,10 +303,10 @@ private fun verifyCrossHunkMove( "missing_import" -> { val missing = moved.filter { it !in fileImports } if (missing.isEmpty()) { - VerifyResult(hunk.file, hunk.hunkType, true, "moved imports restored: ${moved.joinToString(", ")}") + VerifyResult(hunk.id, hunk.file, hunk.hunkType, true, "moved imports restored: ${moved.joinToString(", ")}") } else { VerifyResult( - hunk.file, hunk.hunkType, false, + hunk.id, hunk.file, hunk.hunkType, false, "moved imports still missing: ${missing.joinToString(", ")}", ) } @@ -296,15 +314,15 @@ private fun verifyCrossHunkMove( "spurious_addition" -> { when { snapshotMissing -> VerifyResult( - hunk.file, hunk.hunkType, true, + hunk.id, hunk.file, hunk.hunkType, true, "no pre-run snapshot; spurious_addition cannot be falsified", ) fileChanged -> VerifyResult( - hunk.file, hunk.hunkType, true, + hunk.id, hunk.file, hunk.hunkType, true, "file changed (rotation applied)", ) else -> VerifyResult( - hunk.file, hunk.hunkType, false, + hunk.id, hunk.file, hunk.hunkType, false, "file unchanged since before agent run; rotation not applied", ) } @@ -313,21 +331,21 @@ private fun verifyCrossHunkMove( val missing = moved.filter { it !in fileImports } when { missing.isNotEmpty() -> VerifyResult( - hunk.file, hunk.hunkType, false, + hunk.id, hunk.file, hunk.hunkType, false, "moved imports missing from file: ${missing.joinToString(", ")}", ) snapshotMissing || fileChanged -> VerifyResult( - hunk.file, hunk.hunkType, true, + hunk.id, hunk.file, hunk.hunkType, true, "moved imports present and file changed", ) else -> VerifyResult( - hunk.file, hunk.hunkType, false, + hunk.id, hunk.file, hunk.hunkType, false, "file unchanged since before agent run; rotation not applied", ) } } else -> VerifyResult( - hunk.file, hunk.hunkType, false, + hunk.id, hunk.file, hunk.hunkType, false, "unknown cross_move_role: $role", ) } diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt index 16924a8..de9eab9 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt @@ -4,6 +4,12 @@ import ai.koog.prompt.executor.clients.openai.OpenAIModels import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout import com.github.pderakhshanfar.codecocoonplugin.suggestions.impl.FixHunksInput import com.github.pderakhshanfar.codecocoonplugin.suggestions.impl.runImportHunkFixerAgent +import com.github.pderakhshanfar.codecocoonplugin.suggestions.impl.unappliedSummary +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject import com.intellij.openapi.application.ApplicationStarter import com.intellij.openapi.diagnostic.thisLogger import kotlinx.coroutines.runBlocking @@ -34,6 +40,7 @@ class FixHunksStarter : ApplicationStarter { override fun main(args: List) { val inputFile = System.getProperty("fix.inputFile") ?: "" + val outputFile = System.getProperty("fix.outputFile") ?: "" val batchSize = System.getProperty("fix.batchSize")?.toIntOrNull()?.takeIf { it > 0 } ?: DEFAULT_BATCH_SIZE val maxAgentIterations = System.getProperty("fix.maxAgentIterations")?.toIntOrNull()?.takeIf { it > 0 } ?: DEFAULT_MAX_AGENT_ITERATIONS @@ -83,13 +90,19 @@ class FixHunksStarter : ApplicationStarter { logger.info("[FixHunks] Description: ${input.description}") var failedBatches = 0 + val allFixedIds = mutableListOf() + val allUnfixed = mutableListOf() + val batchSummaries = mutableListOf() + val startedAtMs = System.currentTimeMillis() + runBlocking { for ((index, batch) in batches.withIndex()) { val batchNum = index + 1 val fileCount = batch.map { it.file }.distinct().size + val batchStartedMs = System.currentTimeMillis() logger.info("[FixHunks] Batch $batchNum/${batches.size}: ${batch.size} hunks across $fileCount file(s)") - try { - val result = runImportHunkFixerAgent( + val result = try { + runImportHunkFixerAgent( token = token, model = OpenAIModels.Chat.GPT5Mini, repoRoot = repoRoot.absolutePath, @@ -97,23 +110,96 @@ class FixHunksStarter : ApplicationStarter { hunks = batch, maxAgentIterations = maxAgentIterations, ) - result.fold( - onSuccess = { agentReport -> - logger.info("[FixHunks] Batch $batchNum/${batches.size} done") - logger.info("[FixHunks] Agent report:\n$agentReport") - }, - onFailure = { e -> - failedBatches++ - logger.error("[FixHunks] Batch $batchNum/${batches.size} FAILED: ${e.message}", e) - }, - ) } catch (e: Exception) { failedBatches++ logger.error("[FixHunks] Batch $batchNum/${batches.size} threw: ${e.message}", e) + batch.forEach { allUnfixed += UnfixedRecord(it.id, it.file, it.hunkType, "agent threw: ${e.message}") } + batchSummaries += BatchSummary( + batchNum = batchNum, + hunkIds = batch.map { it.id }, + applied = emptyList(), + unapplied = batch.map { UnfixedRecord(it.id, it.file, it.hunkType, "agent threw: ${e.message}") }, + agentReport = null, + outcome = "agent_threw", + errorMessage = e.message, + elapsedMs = System.currentTimeMillis() - batchStartedMs, + ) + null + } + + if (result != null) { + val unappliedRecords = result.verifications + .filterNot { it.applied } + .map { UnfixedRecord(it.hunkId, it.file, it.hunkType, it.reason) } + allFixedIds += result.appliedHunkIds + allUnfixed += unappliedRecords + val appliedCount = result.verifications.count { it.applied } + val outcome = when { + result.isFullySuccessful -> "success" + result.error != null -> "agent_failed" + else -> "verification_failed" + } + + when (outcome) { + "success" -> logger.info("[FixHunks] Batch $batchNum/${batches.size} done ($appliedCount/${batch.size} applied)") + "agent_failed" -> { + failedBatches++ + val err = result.error!! + logger.error( + "[FixHunks] Batch $batchNum/${batches.size} agent FAILED " + + "($appliedCount/${batch.size} hunks applied before failure): ${err.message}", + err, + ) + } + else -> { + failedBatches++ + logger.error( + "[FixHunks] Batch $batchNum/${batches.size} verification failed " + + "($appliedCount/${batch.size} hunks applied). Unapplied:\n${result.unappliedSummary()}", + ) + } + } + result.agentReport?.let { logger.info("[FixHunks] Agent report:\n$it") } + + batchSummaries += BatchSummary( + batchNum = batchNum, + hunkIds = batch.map { it.id }, + applied = result.appliedHunkIds, + unapplied = unappliedRecords, + agentReport = result.agentReport, + outcome = outcome, + errorMessage = result.error?.message, + elapsedMs = System.currentTimeMillis() - batchStartedMs, + ) } } } + val elapsedMs = System.currentTimeMillis() - startedAtMs + + if (outputFile.isNotEmpty()) { + writeOutputJson( + path = outputFile, + fixedIds = allFixedIds, + input = input, + batchSummaries = batchSummaries, + allUnfixed = allUnfixed, + failedBatches = failedBatches, + totalBatches = batches.size, + batchSize = batchSize, + elapsedMs = elapsedMs, + ) + } + + logger.info("[FixHunks] Total fixed hunks across all batches: ${allFixedIds.size}/${input.hunks.size}") + if (allUnfixed.isNotEmpty()) { + logger.warn("[FixHunks] Unfixed hunks (${allUnfixed.size}):") + allUnfixed.forEach { (id, file, _, reason) -> + val tag = if (id.isNotEmpty()) "$id " else "" + logger.warn("[FixHunks] - $tag$file: $reason") + } + } + if (failedBatches > 0) { logger.error("[FixHunks] Completed with $failedBatches failed batch(es) out of ${batches.size}") exitProcess(1) @@ -122,6 +208,94 @@ class FixHunksStarter : ApplicationStarter { exitProcess(0) } + private data class UnfixedRecord( + val id: String, + val file: String, + val hunkType: String, + val reason: String, + ) + + private data class BatchSummary( + val batchNum: Int, + val hunkIds: List, + val applied: List, + val unapplied: List, + val agentReport: String?, + val outcome: String, // "success" | "verification_failed" | "agent_failed" | "agent_threw" + val errorMessage: String?, + val elapsedMs: Long, + ) + + private fun writeOutputJson( + path: String, + fixedIds: List, + input: FixHunksInput, + batchSummaries: List, + allUnfixed: List, + failedBatches: Int, + totalBatches: Int, + batchSize: Int, + elapsedMs: Long, + ) { + try { + val deduped = fixedIds.distinct() + val obj: JsonObject = buildJsonObject { + put("fixed", buildJsonArray { deduped.forEach { add(JsonPrimitive(it)) } }) + put("unfixed", buildJsonArray { + allUnfixed.forEach { rec -> + add(buildJsonObject { + put("id", JsonPrimitive(rec.id)) + put("file", JsonPrimitive(rec.file)) + put("hunk_type", JsonPrimitive(rec.hunkType)) + put("reason", JsonPrimitive(rec.reason)) + }) + } + }) + put("summary", buildJsonObject { + put("total_hunks", JsonPrimitive(input.hunks.size)) + put("fixed_count", JsonPrimitive(deduped.size)) + put("unfixed_count", JsonPrimitive(allUnfixed.size)) + put("total_batches", JsonPrimitive(totalBatches)) + put("failed_batches", JsonPrimitive(failedBatches)) + put("batch_size", JsonPrimitive(batchSize)) + put("elapsed_ms", JsonPrimitive(elapsedMs)) + put("repo_root", JsonPrimitive(input.repoRoot)) + put("patch_label", JsonPrimitive(input.patchLabel)) + }) + put("agent", buildJsonObject { + put("model", JsonPrimitive(OpenAIModels.Chat.GPT5Mini.id)) + put("batches", buildJsonArray { + batchSummaries.forEach { b -> + add(buildJsonObject { + put("batch_num", JsonPrimitive(b.batchNum)) + put("outcome", JsonPrimitive(b.outcome)) + put("elapsed_ms", JsonPrimitive(b.elapsedMs)) + put("hunk_ids", buildJsonArray { b.hunkIds.forEach { add(JsonPrimitive(it)) } }) + put("applied", buildJsonArray { b.applied.forEach { add(JsonPrimitive(it)) } }) + put("unapplied", buildJsonArray { + b.unapplied.forEach { rec -> + add(buildJsonObject { + put("id", JsonPrimitive(rec.id)) + put("file", JsonPrimitive(rec.file)) + put("hunk_type", JsonPrimitive(rec.hunkType)) + put("reason", JsonPrimitive(rec.reason)) + }) + } + }) + b.errorMessage?.let { put("error", JsonPrimitive(it)) } + b.agentReport?.let { put("report", JsonPrimitive(it)) } + }) + } + }) + }) + } + File(path).also { it.parentFile?.mkdirs() }.writeText(json.encodeToString(JsonObject.serializer(), obj)) + logger.info("[FixHunks] Wrote ${deduped.size} fixed hunk id(s) + agent reports to: $path") + } catch (e: Exception) { + logger.error("[FixHunks] Failed to write output file '$path': ${e.message}", e) + } + } + companion object { private const val DEFAULT_BATCH_SIZE = 8 private const val DEFAULT_MAX_AGENT_ITERATIONS = 70 From be4ecfb4230a72f45fcfd17c5ccacdc11f00e353 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Tue, 5 May 2026 04:27:56 +0200 Subject: [PATCH 56/67] refactor: optimize imports --- .../codecocoonplugin/suggestions/impl/FixImportHunks.kt | 1 - .../codecocoonplugin/appstarter/FixHunksStarter.kt | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt index 3455d67..fe209d2 100644 --- a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/suggestions/impl/FixImportHunks.kt @@ -13,7 +13,6 @@ import ai.koog.prompt.llm.LLModel import ai.koog.rag.base.files.JVMFileSystemProvider import com.github.pderakhshanfar.codecocoonplugin.common.LLM import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.diagnostic.thisLogger import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt index de9eab9..2abaa2c 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt @@ -5,16 +5,11 @@ import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout import com.github.pderakhshanfar.codecocoonplugin.suggestions.impl.FixHunksInput import com.github.pderakhshanfar.codecocoonplugin.suggestions.impl.runImportHunkFixerAgent import com.github.pderakhshanfar.codecocoonplugin.suggestions.impl.unappliedSummary -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.buildJsonObject import com.intellij.openapi.application.ApplicationStarter import com.intellij.openapi.diagnostic.thisLogger import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json +import kotlinx.serialization.json.* import java.io.File import kotlin.system.exitProcess From 6743341108e3a7291e6746c7d210f748c7f3669f Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Tue, 5 May 2026 04:59:53 +0200 Subject: [PATCH 57/67] feat: don't exit with non-zero code for partial fixes in fix hunks agent --- .../appstarter/FixHunksStarter.kt | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt index 2abaa2c..c04c10d 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/FixHunksStarter.kt @@ -147,9 +147,13 @@ class FixHunksStarter : ApplicationStarter { ) } else -> { - failedBatches++ - logger.error( - "[FixHunks] Batch $batchNum/${batches.size} verification failed " + + // Agent ran cleanly to completion but the verifier says some + // hunks didn't take. NOT a process-level failure — we still + // want exit code 0 so the caller's retry pipeline can read + // the output JSON and decide what to redo. Only true agent + // failures (throws / iteration caps) flip the exit code. + logger.warn( + "[FixHunks] Batch $batchNum/${batches.size} partial " + "($appliedCount/${batch.size} hunks applied). Unapplied:\n${result.unappliedSummary()}", ) } @@ -196,10 +200,21 @@ class FixHunksStarter : ApplicationStarter { } if (failedBatches > 0) { - logger.error("[FixHunks] Completed with $failedBatches failed batch(es) out of ${batches.size}") + logger.error( + "[FixHunks] Completed with $failedBatches batch(es) where the agent itself " + + "failed (out of ${batches.size}). Exit code 1.", + ) exitProcess(1) } - logger.info("[FixHunks] SUCCESS: all ${batches.size} batches applied") + if (allUnfixed.isNotEmpty()) { + logger.warn( + "[FixHunks] Agent finished cleanly. ${allFixedIds.size}/${input.hunks.size} hunks " + + "verified applied; ${allUnfixed.size} not applied. Exit code 0 — caller can retry " + + "the unfixed hunks (see output JSON for the list).", + ) + } else { + logger.info("[FixHunks] SUCCESS: all ${input.hunks.size} hunks applied across ${batches.size} batch(es)") + } exitProcess(0) } From f16d8231e660d8cb6248a4c7f0dcea9a3f58f167 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Tue, 5 May 2026 17:11:14 +0200 Subject: [PATCH 58/67] fix: seal variable rename order and signature safety in `RenameVariableTransformation` to avoid additional modifications in base+test_patch run --- .../renaming/RenameVariableTransformation.kt | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt index 9687530..4a80b09 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt @@ -77,6 +77,31 @@ class RenameVariableTransformation( return TransformationResult.Skipped("No matching variables found in ${virtualFile.name}") } + // Snapshot every variable's signature BEFORE any rename happens. Regenerating + // the signature mid-loop is unsafe: when a field is renamed, IntelliJ's + // RenameProcessor cascade-renames the constructor parameter bound to it via + // `this.x = x`, so a later `psiVar.name` for that parameter would already be + // the post-cascade name and the memory key would be wrong. + val signatures: Map = withReadAction { + eligibleVariables.mapNotNull { psiVar -> + PsiSignatureGenerator.generateSignature(psiVar)?.let { psiVar to it } + }.toMap() + } + + // Process inner scopes first so the field-rename cascade has nothing to + // latch onto: by the time a PsiField is renamed, any constructor parameter + // that used to share its name has already been renamed to its own + // LLM-suggested name, so RenameJavaFieldProcessor's "rename the bound + // parameter" condition (matching name) no longer holds. + val orderedVariables = eligibleVariables.sortedBy { v -> + when (v) { + is PsiLocalVariable -> 0 + is PsiParameter -> 1 + is PsiField -> 2 + else -> 3 + } + } + logger.info(" ⏲ Generating rename suggestions for ${eligibleVariables.size} variables...") val renaming = runBlocking { @@ -92,12 +117,11 @@ class RenameVariableTransformation( val saveRenamesInMemory = !useMemory || generateWhenNotInMemory // Try renaming each variable with suggestions until one succeeds - val successfulRenames = eligibleVariables.mapNotNull { psiVar -> + val successfulRenames = orderedVariables.mapNotNull { psiVar -> val varName = withReadAction { psiVar.name } val suggestions = renaming.suggestions[psiVar] ?: return@mapNotNull null - // Generate signature BEFORE renaming - val signature = withReadAction { PsiSignatureGenerator.generateSignature(psiVar) } + val signature = signatures[psiVar] if (signature == null) { logger.warn(" ⊘ Could not generate signature for variable $varName") return@mapNotNull null From 8ce617dc3145538b9c40cf0a8a083883919bef40 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Tue, 5 May 2026 21:55:51 +0200 Subject: [PATCH 59/67] feat: add batching and retry logic for LLM calls in `RenameVariableTransformation` with exponential backoff and robustness enhancements --- .../renaming/RenameVariableTransformation.kt | 74 +++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt index 4a80b09..7f16817 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt @@ -24,6 +24,7 @@ import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.* import com.intellij.psi.util.PsiTreeUtil import com.intellij.refactoring.rename.RenameProcessor +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable @@ -311,6 +312,41 @@ class RenameVariableTransformation( ): List { if (variables.isEmpty()) return emptyList() + // Split variables into chunks so a single transient LLM failure (timeout, + // malformed JSON, token-budget overflow on huge files) doesn't lose the + // suggestions for the entire file. Files with <= LLM_BATCH_SIZE variables + // still produce one LLM call, matching the prior single-call behaviour. + val batches = variables.chunked(LLM_BATCH_SIZE) + if (batches.size > 1) { + logger.info(" ↳ Splitting ${variables.size} variables into ${batches.size} LLM batches of up to $LLM_BATCH_SIZE") + } + + val combined = mutableListOf() + for ((index, batch) in batches.withIndex()) { + val partial = generateBatchWithRetry( + variables = batch, + count = count, + batchLabel = "${index + 1}/${batches.size}", + ) + combined.addAll(partial) + } + return combined + } + + /** + * Calls the LLM for a single batch of variables, retrying transient errors + * with exponential backoff. Returns an empty list if every retry fails — the + * caller's per-variable loop then skips those variables (no rename, no memory + * write) instead of failing the whole transformation, so other batches and + * other files still progress. + * + * Rethrows [ProcessCanceledException] per IntelliJ contract. + */ + private suspend fun generateBatchWithRetry( + variables: List, + count: Int, + batchLabel: String, + ): List { val contexts = readAction { variables.map { buildVariableContext(it) } } val varRenamePrompt = prompt("variable-rename-batch-prompt") { @@ -341,13 +377,31 @@ class RenameVariableTransformation( } } - val llm = LLM.fromGrazie(OpenAIModels.Chat.GPT5Mini) - val result = llm.structuredRequest( - prompt = varRenamePrompt - ) - return result?.renamings ?: emptyList() + var lastError: Throwable? = null + for (attempt in 1..LLM_MAX_ATTEMPTS) { + try { + val result = llm.structuredRequest(prompt = varRenamePrompt) + return result?.renamings ?: emptyList() + } catch (e: ProcessCanceledException) { + throw e + } catch (e: Exception) { + lastError = e + logger.warn(" ⚠ LLM call for variable batch $batchLabel failed (attempt $attempt/$LLM_MAX_ATTEMPTS): ${e.message}") + if (attempt < LLM_MAX_ATTEMPTS) { + delay(retryBackoffMillis(attempt)) + } + } + } + logger.warn(" ✗ LLM call for variable batch $batchLabel exhausted $LLM_MAX_ATTEMPTS attempts; skipping batch (last error: ${lastError?.message})") + return emptyList() + } + + private fun retryBackoffMillis(attempt: Int): Long { + // 1s, 2s, 4s, ... capped at LLM_RETRY_BACKOFF_CAP_MILLIS + val base = LLM_RETRY_BACKOFF_BASE_MILLIS shl (attempt - 1).coerceAtLeast(0) + return base.coerceAtMost(LLM_RETRY_BACKOFF_CAP_MILLIS) } private fun tryRenameVariableAndUsages( @@ -536,6 +590,16 @@ class RenameVariableTransformation( const val ID = "rename-variable-transformation" private const val DEFAULT_SUGGESTED_NAMES_SIZE = 3 + // Robustness knobs for the LLM batch call. + // LLM_BATCH_SIZE chosen so files with up to a few dozen variables still + // produce one call (preserving the prior single-call behaviour) while + // very large files are split, so a single transient failure no longer + // wipes out the whole file's renames. + private const val LLM_BATCH_SIZE = 50 + private const val LLM_MAX_ATTEMPTS = 3 + private const val LLM_RETRY_BACKOFF_BASE_MILLIS = 1_000L + private const val LLM_RETRY_BACKOFF_CAP_MILLIS = 8_000L + /** * Default blacklisted variable annotations (framework/infrastructure annotations). * These annotations typically indicate variables that are mapped to external systems, From f359ffe3b46993db92e5b30a77ef3efc004f6862 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Wed, 6 May 2026 18:37:44 +0200 Subject: [PATCH 60/67] fix: rename all overloaded overrides in `RenameMethodTransformation` by explicitly attaching transitive overriders to the rename processor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RenameProcessor`'s implicit override expansion via `RenameJavaMethodProcessor.prepareRenaming` + `OverridingMethodsSearch` silently dropped sibling overloads' overriders when multiple same-name overloads were renamed to the same target name through a single processor — only the seed overload's overrider got renamed, the others kept the old name. The post-rename safety net `verifyAndPatchMissedCallSites` only inspected `PsiMethodCallExpression` nodes, never `PsiMethod` declarations, so missed override definitions were invisible to it. Reproducer: an abstract base `A` with `write(JsonValue)` + `write(String)` and `B extends A` overriding both. After renaming `A.write` to `A.writeTo`, `B.write(JsonValue)` followed but `B.write(String)` was left dangling with the old name. In `tryRenameMethodFamily`, after the existing overload-sibling `addElement` loop, enumerate transitive overriders via `OverridingMethodsSearch.search(method, checkDeep = true)` for every family method and attach each (skipping family members already added and overriders in libraries) as a first-class rename target. Adding them explicitly forces the same rename path that already works for the seed; when implicit expansion would have caught them anyway, it is a no-op via `myAllRenames` dedup, so the change is backward compatible. --- .../renaming/RenameMethodTransformation.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 5a2a854..3e96a1d 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -27,6 +27,7 @@ import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.* import com.intellij.psi.search.FileTypeIndex import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.search.searches.OverridingMethodsSearch import com.intellij.psi.search.searches.ReferencesSearch import com.intellij.psi.util.PsiTreeUtil import com.intellij.refactoring.rename.RenameProcessor @@ -409,6 +410,30 @@ class RenameMethodTransformation( for (extra in methods.drop(1)) { processor.addElement(extra, newName) } + + // Pre-emptively attach transitive overriders. RenameProcessor's implicit + // expansion via RenameJavaMethodProcessor.prepareRenaming + + // OverridingMethodsSearch is unreliable when several same-name overloads + // are added to one processor and renamed to the same target name — + // observed: only the seed overload's overrider got renamed, sibling + // overloads' overriders were silently left with the old name. Adding + // them as first-class entries in myAllRenames forces the same rename + // path that already works for the seed. + val familyMethodSet = methods.toHashSet() + val projectFileIndex = ProjectFileIndex.getInstance(project) + val attachedOverriders = LinkedHashSet() + for (method in methods) { + OverridingMethodsSearch.search(method, /* checkDeep = */ true).findAll().forEach { overrider -> + if (overrider in familyMethodSet) return@forEach + if (!attachedOverriders.add(overrider)) return@forEach + val vf = overrider.containingFile?.virtualFile + if (vf != null && projectFileIndex.isInLibrary(vf)) return@forEach + processor.addElement(overrider, newName) + } + } + if (attachedOverriders.isNotEmpty()) { + logger.info(" ↳ Attached ${attachedOverriders.size} transitive overrider(s) to rename processor") + } processor } @@ -512,6 +537,10 @@ class RenameMethodTransformation( val psiFile = psiManager.findFile(vf) as? PsiJavaFile ?: continue val document = docManager.getDocument(psiFile) psiFile.accept(object : JavaRecursiveElementVisitor() { + /*override fun visitMethod(method: PsiMethod) { + super.visitMethod(method) + }*/ + override fun visitMethodCallExpression(expr: PsiMethodCallExpression) { super.visitMethodCallExpression(expr) val refExpr = expr.methodExpression From e53f516532e25f610bd97abddcd90a2cc7dc5427 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Wed, 6 May 2026 18:49:50 +0200 Subject: [PATCH 61/67] feat: add rich logging to `RenameMethodTransformation` --- .../renaming/RenameMethodTransformation.kt | 64 ++++++++++++++++--- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 3e96a1d..06419cb 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -399,6 +399,7 @@ class RenameMethodTransformation( val firstMethod = methods.first() val oldName = IntelliJAwareTransformation.withReadAction { firstMethod.name } + var attachedOverridersCount = 0 val renameProcessor = IntelliJAwareTransformation.withReadAction { val processor = RenameProcessor( /* project = */ project, @@ -431,9 +432,29 @@ class RenameMethodTransformation( processor.addElement(overrider, newName) } } - if (attachedOverriders.isNotEmpty()) { - logger.info(" ↳ Attached ${attachedOverriders.size} transitive overrider(s) to rename processor") + attachedOverridersCount = attachedOverriders.size + + // Full target table — family + attached overriders — for debuggability + val totalTargets = methods.size + attachedOverriders.size + logger.info(" ↳ Rename processor targets ($totalTargets method(s) total):") + val targetDisplayLimit = 10 + var shown = 0 + for (method in methods) { + if (shown >= targetDisplayLimit) break + val sig = PsiSignatureGenerator.generateSignature(method) ?: "" + logger.info(" [family] $sig") + shown++ + } + for (overrider in attachedOverriders) { + if (shown >= targetDisplayLimit) break + val sig = PsiSignatureGenerator.generateSignature(overrider) ?: "" + logger.info(" [overrider] $sig") + shown++ } + if (totalTargets > targetDisplayLimit) { + logger.info(" ... (${totalTargets - targetDisplayLimit} more)") + } + processor } @@ -441,14 +462,38 @@ class RenameMethodTransformation( // the pre-rename PSI to return the references that will actually // be rewritten. After run() the seed element has been renamed and // the result is unreliable. + var usageCount = 0 val modifiedFiles = IntelliJAwareTransformation.withReadAction { val files = mutableSetOf() - renameProcessor.findUsages().forEach { usageInfo -> - usageInfo.file?.let { files.add(it) } + val docManager = PsiDocumentManager.getInstance(project) + val usagePaths = mutableListOf() + val usages = renameProcessor.findUsages() + for (usageInfo in usages) { + val file = usageInfo.file ?: continue + files.add(file) + val element = usageInfo.element + val vf = file.virtualFile + if (element != null && vf != null) { + val doc = docManager.getDocument(file) + val line = doc?.getLineNumber(element.textRange.startOffset)?.plus(1) ?: -1 + val path = project.relativeToRootOrAbsPath(vf) + usagePaths.add("$path:$line") + } } for (method in methods) { method.containingFile?.let { files.add(it) } } + usageCount = usages.size + + if (usagePaths.isNotEmpty()) { + logger.info(" ↳ Rename processor will rewrite ${usagePaths.size} usage(s):") + val usageDisplayLimit = 10 + usagePaths.take(usageDisplayLimit).forEach { logger.info(" $it") } + if (usagePaths.size > usageDisplayLimit) { + logger.info(" ... (${usagePaths.size - usageDisplayLimit} more)") + } + } + files } @@ -478,7 +523,12 @@ class RenameMethodTransformation( } val overloadLabel = if (methods.size > 1) "${methods.size} overloads" else "1 overload" - logger.info(" • Renamed `$oldName` ($overloadLabel) to `$newName` in ${modifiedFiles.size} files") + val totalMethodsRenamed = methods.size + attachedOverridersCount + logger.info( + " • Renamed `$oldName` ($overloadLabel) to `$newName`: " + + "$totalMethodsRenamed method(s) renamed, " + + "$usageCount usage(s) rewritten across ${modifiedFiles.size} file(s)" + ) modifiedFiles } catch (e: ProcessCanceledException) { // Must rethrow control flow exceptions @@ -537,10 +587,6 @@ class RenameMethodTransformation( val psiFile = psiManager.findFile(vf) as? PsiJavaFile ?: continue val document = docManager.getDocument(psiFile) psiFile.accept(object : JavaRecursiveElementVisitor() { - /*override fun visitMethod(method: PsiMethod) { - super.visitMethod(method) - }*/ - override fun visitMethodCallExpression(expr: PsiMethodCallExpression) { super.visitMethodCallExpression(expr) val refExpr = expr.methodExpression From 5eaae493502ac6c86f0eccebe6a1ca4bd457256d Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Wed, 6 May 2026 22:16:10 +0200 Subject: [PATCH 62/67] feat: log transformations application order before running them --- .../codecocoonplugin/services/TransformationService.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt index 0b07f9c..937a0e7 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt @@ -145,6 +145,10 @@ class TransformationService { // Create a global memory instance for the entire project // Memory is automatically saved via .use {} when the block exits PersistentMemory(config.memoryFilepath).use { memory -> + val transformationsStr = transformations.withIndex() + .joinToString(separator = ",\n") { " ${it.index + 1}) ${it.value.id}" } + logger.info("[TransformationService] Applying the following transformations in order:\n$transformationsStr") + logger.info("[TransformationService] Created global memory at '${config.memoryFilepath}'") val succeededIds = mutableListOf() @@ -181,6 +185,7 @@ class TransformationService { } logger.info("[TransformationService] Successfully collected (and filtered) ${filteredFileContexts.size} file contexts") + // for each file, apply all transformations for (context in filteredFileContexts) { val filepath = project.relativeToRootOrAbsPath(context.virtualFile) From 5b35bc718397f8c2cf1fe6f82eb6c35c8b623367 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Wed, 6 May 2026 22:29:33 +0200 Subject: [PATCH 63/67] feat: deem file move transformation as skipped when no suggestions fit --- .../MoveFileIntoSuggestedDirectoryTransformation.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt index 7fb24e1..368f3bc 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/structural/MoveFileIntoSuggestedDirectoryTransformation.kt @@ -328,10 +328,15 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( } } - // no suggestions fit - logger.info(" ✗ Failed to move $filename: None of ${suggestions.size} suggestions fit") - return TransformationResult.Failure( - "Failed to move $filename into any of ${suggestions.size} suggested directories:\n${suggestions.joinToString("\n") { " - $it" }}") + // No suggestion fit — every iteration above set `successfullyMoved` to false + // (conflict callback / exception in processor.run() / 3-minute timeout). Treat this + // as Skipped rather than Failure: the input file is well-formed, suggestions were + // obtained, the move processor just rejected every target. Genuine failures + // (non-Java input, missing project root, suggestion-API error) still return Failure + // upstream of this loop. + logger.info(" ⊘ Skipped moving $filename: move processor rejected all ${suggestions.size} suggestion(s)") + return TransformationResult.Skipped( + "Skipped moving $filename: move processor rejected all ${suggestions.size} suggested directories (conflicts/exceptions/timeouts):\n${suggestions.joinToString("\n") { " - $it" }}") } /** From 1452eadece7e39e0b979e6af6fff5f51fa9672ad Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Wed, 6 May 2026 22:29:53 +0200 Subject: [PATCH 64/67] feat: forbid case-only change in renaming for classes in `RenameClassTransformation` --- .../renaming/RenameClassTransformation.kt | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt index b2b796c..2c63020 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt @@ -253,6 +253,10 @@ class RenameClassTransformation( +"Example structure:" +"{\"suggestions\": [\"BestFittingRename\", \"SecondBestFittingRename\", ... ]}" +"Every suggestion must be a valid Java identifier and semantically similar to the original name." + +("Suggestions MUST differ from the original name by more than letter case alone — " + + "case-only renames (e.g. `JSON` → `Json`, `XML` → `Xml`) are forbidden because they collide " + + "on case-insensitive filesystems. Add or change letters/words instead " + + "(e.g. `JSON` → `JsonMapper`, `XML` → `XmlDocument`).") } } @@ -262,10 +266,10 @@ class RenameClassTransformation( prompt = classRenamePrompt ) - return if (result != null) buildSuggestionList(result.suggestions) else emptyList() + return if (result != null) buildSuggestionList(result.suggestions, context.className) else emptyList() } - private fun buildSuggestionList(rawSuggestions: List): List { + private fun buildSuggestionList(rawSuggestions: List, originalName: String): List { val normalized = rawSuggestions .asSequence() .map { it.trim() } @@ -273,10 +277,18 @@ class RenameClassTransformation( .distinct() .toList() - val firstSuggestion = normalized.firstOrNull() ?: return emptyList() + // Defensive filter: drop suggestions that equal the original name case-insensitively + // (covers both same-as-original and case-only variants like `JSON` -> `Json`, which + // collide with the source file on case-insensitive filesystems and break `git apply`). + val (kept, dropped) = normalized.partition { !it.equals(originalName, ignoreCase = true) } + if (dropped.isNotEmpty()) { + logger.info(" ⊘ Dropped ${dropped.size} case-only/identical suggestion(s) for `$originalName`: ${dropped.joinToString(", ")}") + } + + val firstSuggestion = kept.firstOrNull() ?: return emptyList() val internalFallback = "${firstSuggestion}Internal" - return if (normalized.contains(internalFallback)) normalized else normalized + internalFallback + return if (kept.contains(internalFallback)) kept else kept + internalFallback } private fun tryRenameClassAndUsages( From 2ed7dbd3b8539e0d226777516d56f293b7e36c9e Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Fri, 8 May 2026 01:37:13 +0200 Subject: [PATCH 65/67] feat(`RenameVariableTransformation`): batchsize: 40, base retry backoff: 2s --- .../transformations/renaming/RenameVariableTransformation.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt index 7f16817..1249576 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameVariableTransformation.kt @@ -595,9 +595,9 @@ class RenameVariableTransformation( // produce one call (preserving the prior single-call behaviour) while // very large files are split, so a single transient failure no longer // wipes out the whole file's renames. - private const val LLM_BATCH_SIZE = 50 + private const val LLM_BATCH_SIZE = 40 private const val LLM_MAX_ATTEMPTS = 3 - private const val LLM_RETRY_BACKOFF_BASE_MILLIS = 1_000L + private const val LLM_RETRY_BACKOFF_BASE_MILLIS = 2_000L private const val LLM_RETRY_BACKOFF_CAP_MILLIS = 8_000L /** From 5ad56d7075f1d224a3a0b68fff32e1360ca6cf29 Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Fri, 8 May 2026 01:47:08 +0200 Subject: [PATCH 66/67] feat: add batching and retry logic for LLM calls in `RenameMethodTransformation` with exponential backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the robust LLM call path already used in `RenameVariableTransformation`: each file now issues a single batched LLM request listing every overload family (chunked at LLM_BATCH_SIZE=20 to keep prompt size manageable since each entry embeds a method body), with up to LLM_MAX_ATTEMPTS=3 retries per batch and exponential backoff (4s → 8s, capped at 12s) on transient failures. `ProcessCanceledException` is rethrown per IntelliJ contract; permanent failures return an empty list so affected families are skipped (no rename, no memory write) without failing the whole transformation, and other batches and other files keep progressing. Wire format: replace `MethodNameSuggestions(suggestions)` with `MethodFamilyRenaming(familyKey, suggestions)` + `MethodRenameSuggestions(renamings)`. Each overload family carries a precomputed `familyKey` of the form `#[]` so the model can echo it back per entry — the `[static]/[instance]` tag prevents same-name static/instance siblings in the same class from colliding in the batch. `generateRenamesForFamilies` now matches results back via `familyKey == family.familyKey` and pipes them through the unchanged `buildSuggestionList` helper. `extractRenamesFromMemoryForFamilies`, `tryRenameMethodFamily`, the overrider-attachment / verify-and-patch / rich-logging code, and the memory key format are untouched. --- .../renaming/RenameMethodTransformation.kt | 156 ++++++++++++++---- 1 file changed, 128 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 06419cb..99a72aa 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -31,6 +31,7 @@ import com.intellij.psi.search.searches.OverridingMethodsSearch import com.intellij.psi.search.searches.ReferencesSearch import com.intellij.psi.util.PsiTreeUtil import com.intellij.refactoring.rename.RenameProcessor +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable @@ -204,22 +205,37 @@ class RenameMethodTransformation( } @Serializable - private data class MethodNameSuggestions(val suggestions: List) + private data class MethodFamilyRenaming(val familyKey: String, val suggestions: List) - private data class MethodContext( + @Serializable + private data class MethodRenameSuggestions(val renamings: List) + + private data class MethodFamilyContext( + val familyKey: String, val methodName: String, val methodBody: String?, - val className: String? + val className: String?, ) /** * Represents a family of overloaded methods (same name, same containing class). + * + * [familyKey] uniquely identifies the family within a batched LLM prompt so the + * model can echo it back per-entry. Format: `#[]`. + * The `[static]/[instance]` tag is required because a class can hold a static and + * an instance method with the same name — these are separate families and must + * not collide on the key. */ private data class OverloadFamily( val methodName: String, val containingClass: PsiClass, - val methods: List + val methods: List, + val isStatic: Boolean, + val classFqn: String, ) { + val familyKey: String = + "$classFqn#$methodName[${if (isStatic) "static" else "instance"}]" + /** * Returns a representative method for generating rename suggestions. * Prefers methods with bodies (non-abstract) for better context. @@ -243,12 +259,15 @@ class RenameMethodTransformation( ) } - return grouped.map { (_, methodsInFamily) -> + return grouped.map { (key, methodsInFamily) -> + val (classFqn, methodName, isStatic) = key val representative = methodsInFamily.first() OverloadFamily( - methodName = representative.name, + methodName = methodName, containingClass = representative.containingClass!!, - methods = methodsInFamily + methods = methodsInFamily, + isStatic = isStatic, + classFqn = classFqn, ) } } @@ -314,48 +333,120 @@ class RenameMethodTransformation( } /** - * Generates rename suggestions for overload families using LLM. + * Generates rename suggestions for overload families using a batched LLM call. * Returns the same suggestions for all methods in a family. */ private suspend fun generateRenamesForFamilies(families: List): Map> { + val batchRenamings = generateNewMethodNames(families) return families.associateWith { family -> - // Generate suggestions based on the representative method - val representative = family.getRepresentative() - generateNewMethodNames(representative) + val renaming = batchRenamings.find { it.familyKey == family.familyKey } + renaming?.suggestions?.let { buildSuggestionList(it) } ?: emptyList() } } - private suspend fun generateNewMethodNames(method: PsiMethod, count: Int = DEFAULT_SUGGESTED_NAMES_SIZE): List { - // Extract all PSI data in a read action before building the prompt - val context = readAction { - MethodContext( - methodName = method.name, - methodBody = method.body?.text, - className = method.containingClass?.name + /** + * Splits [families] into chunks so a single transient LLM failure (timeout, + * malformed JSON, token-budget overflow on huge files) doesn't lose suggestions + * for the entire file. Files with <= LLM_BATCH_SIZE families still produce + * one LLM call. + */ + private suspend fun generateNewMethodNames( + families: List, + count: Int = DEFAULT_SUGGESTED_NAMES_SIZE, + ): List { + if (families.isEmpty()) return emptyList() + + val batches = families.chunked(LLM_BATCH_SIZE) + if (batches.size > 1) { + logger.info(" ↳ Splitting ${families.size} families into ${batches.size} LLM batches of up to $LLM_BATCH_SIZE") + } + + val combined = mutableListOf() + for ((index, batch) in batches.withIndex()) { + val partial = generateBatchWithRetry( + families = batch, + count = count, + batchLabel = "${index + 1}/${batches.size}", ) + combined.addAll(partial) } + return combined + } - val methodRenamePrompt = prompt("method-rename-prompt") { + /** + * Calls the LLM for a single batch of families, retrying transient errors with + * exponential backoff. Returns an empty list if every retry fails — the caller's + * per-family loop then skips those families (no rename, no memory write) instead + * of failing the whole transformation, so other batches and other files still + * progress. + * + * Rethrows [ProcessCanceledException] per IntelliJ contract. + */ + private suspend fun generateBatchWithRetry( + families: List, + count: Int, + batchLabel: String, + ): List { + val contexts = readAction { + families.map { family -> + val rep = family.getRepresentative() + MethodFamilyContext( + familyKey = family.familyKey, + methodName = rep.name, + methodBody = rep.body?.text, + className = rep.containingClass?.name, + ) + } + } + + val methodRenamePrompt = prompt("method-rename-batch-prompt") { system { +"You are an agent that proposes semantically similar Java method names." +"Your output is used in a metamorphic transformation pipeline." +"Your output will be parsed into JSON; strictly follow the required structure." } user { - +"The current method name is: ${context.methodName}" - +"The method body is: ${context.methodBody}" - +"The containing class name is: ${context.className}" - +"Return a JSON object with field 'suggestions' which is an ordered array of $count Java identifiers, from most to least fitting." - +"Every suggestion must be a valid Java identifier and semantically similar to the original name." + +"Generate $count semantically similar name suggestions for each of the following Java methods:" + +"" + for (ctx in contexts) { + +"Method (key=${ctx.familyKey}): ${ctx.methodName}" + +" Containing class: ${ctx.className}" + +" Body: ${ctx.methodBody}" + +"" + } + +"Return a JSON object with field 'renamings' which is an array of objects." + +"Each object must have 'familyKey' (echoed verbatim from the input) and 'suggestions' (an ordered array of $count valid Java identifiers, from most to least fitting)." + +"Example schema: {\"renamings\": [{\"familyKey\": \"\", \"suggestions\": [\"newName1\", \"newName2\", ...]}]}" + +"Every suggestion must be a valid Java identifier and semantically similar to the original method name." + +"IMPORTANT: The 'familyKey' field MUST exactly match the key from the input (do not invent new keys, do not reformat)." } } val llm = LLM.fromGrazie(OpenAIModels.Chat.GPT5Mini) - val result = llm.structuredRequest( - prompt = methodRenamePrompt - ) - return if (result != null) buildSuggestionList(result.suggestions) else emptyList() + var lastError: Throwable? = null + for (attempt in 1..LLM_MAX_ATTEMPTS) { + try { + val result = llm.structuredRequest(prompt = methodRenamePrompt) + return result?.renamings ?: emptyList() + } catch (e: ProcessCanceledException) { + throw e + } catch (e: Exception) { + lastError = e + logger.warn(" ⚠ LLM call for method batch $batchLabel failed (attempt $attempt/$LLM_MAX_ATTEMPTS): ${e.message}") + if (attempt < LLM_MAX_ATTEMPTS) { + delay(retryBackoffMillis(attempt)) + } + } + } + logger.warn(" ✗ LLM call for method batch $batchLabel exhausted $LLM_MAX_ATTEMPTS attempts; skipping batch (last error: ${lastError?.message})") + return emptyList() + } + + private fun retryBackoffMillis(attempt: Int): Long { + // 4s, 8s, 16s, ... capped at LLM_RETRY_BACKOFF_CAP_MILLIS + val base = LLM_RETRY_BACKOFF_BASE_MILLIS shl (attempt - 1).coerceAtLeast(0) + return base.coerceAtMost(LLM_RETRY_BACKOFF_CAP_MILLIS) } private fun buildSuggestionList(rawSuggestions: List): List { @@ -1044,5 +1135,14 @@ class RenameMethodTransformation( ) private const val DEFAULT_SUGGESTED_NAMES_SIZE = 5 + + // Robustness knobs for the LLM batch call. + // LLM_BATCH_SIZE is smaller than the variable transformation's value (40) + // because each entry in the method prompt embeds a method body, so token + // budget per entry is materially higher. + private const val LLM_BATCH_SIZE = 30 + private const val LLM_MAX_ATTEMPTS = 3 + private const val LLM_RETRY_BACKOFF_BASE_MILLIS = 4_000L + private const val LLM_RETRY_BACKOFF_CAP_MILLIS = 12_000L } } \ No newline at end of file From 36823e7bc503f7591cd022546cba64dcbcf0f8ff Mon Sep 17 00:00:00 2001 From: Vladislav Artiukhov Date: Fri, 8 May 2026 02:16:56 +0200 Subject: [PATCH 67/67] fix: avoid `IndexNotReadyException` in rename transformations by promoting discovery reads to smart-mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RenameProcessor.run()` + `commitAllDocuments()` + `saveAllDocuments()` drops the IDE back into dumb mode while the stub index is recomputed, so the next file's transformation can land mid-reindex. Any read action that resolves a super-class hierarchy then hits the stub index and throws. Reproduced on Multi-SWE-Bench: - `RenameClassTransformation` — `psiClass.allFields` walks `MemberCache` → `getSupers()` → `findSpecialSuperClass()` → `JavaFullClassNameIndex`. - `RenameMethodTransformation` — `method.findSuperMethods()` builds a hierarchical signature → `getSuperTypes()` → `findClass(java.lang.Object)`. Per the `IndexNotReadyException` Javadoc, promote the topmost read action to smart mode. Add a `withSmartReadAction(project) { ... }` companion helper on `IntelliJAwareTransformation` (mirrors `withReadAction` but uses suspending `smartReadAction(project)`, so the block waits for index readiness before running) and use it at the discovery sites: - `RenameClassTransformation.apply` — the `findAllValidClasses(...)` wrap (also covers the per-class `ReferencesSearch.search(cls)` and `fileIndex.isInTestSourceContent(...)` calls inside). - `RenameClassTransformation.generateNewClassNames` — the PSI-context extraction (covers `psiClass.allFields` — the exact line that threw). - `RenameMethodTransformation.apply` — the `findAllValidMethodFamilies(...)` wrap (covers `findSuperMethods()`, `psiClass.supers`, and `ReferencesSearch.search(method)` inside the override-filter walk). `tryRenameClassAndUsages` and `tryRenameMethodFamily` keep using plain `withReadAction { ... }` — they run after the discovery walk (the natural index-settling point) and adding `runBlocking` inside their existing `invokeAndWait` envelopes risks deadlocks. Cancellation contract preserved: `smartReadAction` honours `ProcessCanceledException` propagation. --- .../IntelliJAwareTransformation.kt | 18 ++++++++++++++++++ .../renaming/RenameClassTransformation.kt | 16 ++++++++++++---- .../renaming/RenameMethodTransformation.kt | 10 ++++++++-- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/IntelliJAwareTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/IntelliJAwareTransformation.kt index 126393c..895054a 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/IntelliJAwareTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/IntelliJAwareTransformation.kt @@ -4,6 +4,8 @@ import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.memory.Memory import com.github.pderakhshanfar.codecocoonplugin.transformation.Transformation import com.intellij.openapi.application.readAction +import com.intellij.openapi.application.smartReadAction +import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiFile import kotlinx.coroutines.runBlocking @@ -42,5 +44,21 @@ interface IntelliJAwareTransformation : Transformation { */ inline fun withReadAction(crossinline block: () -> T): T = runBlocking { readAction { block() } } + + /** + * Smart-mode equivalent of [withReadAction]: suspends until the IntelliJ + * indices are ready (i.e., not in dumb mode) before running [block]. Use + * this anywhere [block] reaches into the stub / file-based index — e.g. + * `psiClass.allFields`, `psiClass.supers`, `method.findSuperMethods()`, + * `JavaPsiFacade.findClass(...)`, `ReferencesSearch.search(...)`. + * + * Background: writes from earlier files in the pipeline (rename commits + * + document save) drop the IDE back into dumb mode while the index is + * recomputed. Plain [withReadAction] holds a read lock but does not wait + * for smart mode, so any index touch in that window throws + * [com.intellij.openapi.project.IndexNotReadyException]. + */ + inline fun withSmartReadAction(project: Project, crossinline block: () -> T): T = + runBlocking { smartReadAction(project) { block() } } } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt index 2c63020..03506dd 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameClassTransformation.kt @@ -4,10 +4,10 @@ import ai.koog.prompt.dsl.prompt import ai.koog.prompt.executor.clients.openai.OpenAIModels import com.github.pderakhshanfar.codecocoonplugin.common.LLM import com.github.pderakhshanfar.codecocoonplugin.components.transformations.IntelliJAwareTransformation.Companion.withReadAction +import com.github.pderakhshanfar.codecocoonplugin.components.transformations.IntelliJAwareTransformation.Companion.withSmartReadAction import com.github.pderakhshanfar.codecocoonplugin.components.transformations.SelfManagedTransformation import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout -import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.allowedAnnotationsOnly import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.document import com.github.pderakhshanfar.codecocoonplugin.intellij.vfs.relativeToRootOrAbsPath import com.github.pderakhshanfar.codecocoonplugin.java.JavaTransformation @@ -15,7 +15,7 @@ import com.github.pderakhshanfar.codecocoonplugin.memory.Memory import com.github.pderakhshanfar.codecocoonplugin.memory.PsiSignatureGenerator import com.github.pderakhshanfar.codecocoonplugin.transformation.requireOrDefault import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.readAction +import com.intellij.openapi.application.smartReadAction import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.progress.ProcessCanceledException @@ -78,7 +78,11 @@ class RenameClassTransformation( val document = withReadAction { psiFile.document() } val modifiedFiles = mutableSetOf() val value = if (document != null) { - val eligibleClasses: List = withReadAction { + // Smart-read: findAllValidClasses calls ReferencesSearch.search(cls) + // and fileIndex.isInTestSourceContent(...) for every class — both + // hit the stub index and would throw IndexNotReadyException if a + // prior file's rename pushed us into dumb mode. + val eligibleClasses: List = withSmartReadAction(psiFile.project) { findAllValidClasses( psiFile = psiFile, annotationFilterMode = annotationFilterMode, @@ -225,7 +229,11 @@ class RenameClassTransformation( } private suspend fun generateNewClassNames(psiClass: PsiClass, count: Int = DEFAULT_SUGGESTED_NAMES_SIZE): List { - val context = readAction { + // Smart-read: psiClass.allFields walks the inheritance chain and + // resolves super references via JavaPsiFacade.findClass -> stub index. + // A plain readAction { ... } here can land mid-reindex (after prior + // files' renames invalidated the index) and throw IndexNotReadyException. + val context = smartReadAction(psiClass.project) { val type = when { psiClass.isInterface -> "interface" psiClass.isEnum -> "enum class" diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt index 99a72aa..83c50f8 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/renaming/RenameMethodTransformation.kt @@ -85,8 +85,14 @@ class RenameMethodTransformation( val document = IntelliJAwareTransformation.withReadAction { psiFile.document() } val modifiedFiles = mutableSetOf() val value = if (document != null) { - // Find all valid method families (already grouped and filtered) - val overloadFamilies: List = IntelliJAwareTransformation.withReadAction { + // Find all valid method families (already grouped and filtered). + // Smart-read: findAllValidMethodFamilies calls method.findSuperMethods(), + // psiClass.supers, and ReferencesSearch.search(method) — all hit the + // stub index. After earlier files' renames the IDE often drops back + // into dumb mode while reindexing; a plain withReadAction { ... } here + // would throw IndexNotReadyException as soon as the override-filter + // loop reached findSuperMethods(). + val overloadFamilies: List = IntelliJAwareTransformation.withSmartReadAction(psiFile.project) { findAllValidMethodFamilies( psiFile = psiFile, annotationFilterMode = annotationFilterMode,