diff --git a/.idea/modules.xml b/.idea/modules.xml
index 6a686fddb581..6dc3829ef071 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -989,10 +989,12 @@
+
+
diff --git a/build/bazel-generated-file-list.txt b/build/bazel-generated-file-list.txt
index 75e9783e1eef..a89804546598 100644
--- a/build/bazel-generated-file-list.txt
+++ b/build/bazel-generated-file-list.txt
@@ -486,10 +486,12 @@ platform/bootstrap
platform/bootstrap/coroutine
platform/bootstrap/dev
platform/build-scripts
+platform/build-scripts/api
platform/build-scripts/codeOptimizer
platform/build-scripts/dev-server
platform/build-scripts/downloader
platform/build-scripts/icons
+platform/build-scripts/product-dsl
platform/build-scripts/testFramework
platform/build-scripts/tests
platform/build-scripts/usages
diff --git a/build/src/org/jetbrains/intellij/build/IdeaCommunityProperties.kt b/build/src/org/jetbrains/intellij/build/IdeaCommunityProperties.kt
index fa300ffd3196..0a7c9cae9205 100644
--- a/build/src/org/jetbrains/intellij/build/IdeaCommunityProperties.kt
+++ b/build/src/org/jetbrains/intellij/build/IdeaCommunityProperties.kt
@@ -40,12 +40,6 @@ internal suspend fun createCommunityBuildContext(
)
open class IdeaCommunityProperties(private val communityHomeDir: Path) : JetBrainsProductProperties() {
- override val moduleSetsProviders: List
- get() = listOf(CommunityModuleSets)
-
- override val baseFileName: String
- get() = "idea"
-
init {
configurePropertiesForAllEditionsOfIntelliJIdea(this)
platformPrefix = "Idea"
@@ -65,11 +59,11 @@ open class IdeaCommunityProperties(private val communityHomeDir: Path) : JetBrai
productLayout.prepareCustomPluginRepositoryForPublishedPlugins = false
productLayout.buildAllCompatiblePlugins = true
- productLayout.pluginLayouts = CommunityRepositoryModules.COMMUNITY_REPOSITORY_PLUGINS.addAll(listOf(
+ productLayout.pluginLayouts = CommunityRepositoryModules.COMMUNITY_REPOSITORY_PLUGINS + persistentListOf(
JavaPluginLayout.javaPlugin(),
CommunityRepositoryModules.androidPlugin(allPlatforms = true),
CommunityRepositoryModules.groovyPlugin(),
- ))
+ )
productLayout.addPlatformSpec { layout, _ ->
layout.withModule("intellij.platform.structuralSearch")
@@ -78,12 +72,12 @@ open class IdeaCommunityProperties(private val communityHomeDir: Path) : JetBrai
productLayout.skipUnresolvedContentModules = true
mavenArtifacts.forIdeModules = true
- mavenArtifacts.additionalModules = mavenArtifacts.additionalModules.addAll(MAVEN_ARTIFACTS_ADDITIONAL_MODULES)
- mavenArtifacts.squashedModules = mavenArtifacts.squashedModules.addAll(persistentListOf(
+ mavenArtifacts.additionalModules += MAVEN_ARTIFACTS_ADDITIONAL_MODULES
+ mavenArtifacts.squashedModules += persistentListOf(
"intellij.platform.util.base",
"intellij.platform.util.base.multiplatform",
"intellij.platform.util.zip",
- ))
+ )
mavenArtifacts.validateForMavenCentralPublication = { module ->
JewelMavenArtifacts.isPublishedJewelModule(module)
}
@@ -120,47 +114,19 @@ open class IdeaCommunityProperties(private val communityHomeDir: Path) : JetBrai
additionalVmOptions = persistentListOf("-Dllm.show.ai.promotion.window.on.start=false")
}
- override fun getProductContentDescriptor(): ProductModulesContentSpec {
- return getIntelliJCommunityProductContentDescriptor(includeCommunityExtensions = true)
+ override val moduleSetsProviders: List
+ get() = listOf(CommunityModuleSets)
+
+ override val baseFileName: String
+ get() = "idea"
+
+ override fun getProductContentDescriptor(): ProductModulesContentSpec = productModules {
+ include(intellijCommunityBaseFragment())
+ include(communityExtensionsFragment())
}
- protected fun getIntelliJCommunityProductContentDescriptor(
- includeCommunityExtensions: Boolean,
- ): ProductModulesContentSpec = productModules {
- alias("com.intellij.modules.idea")
- alias("com.intellij.modules.idea.community")
- alias("com.intellij.modules.java-capable")
- alias("com.intellij.modules.python-core-capable")
- alias("com.intellij.modules.python-in-non-pycharm-ide-capable")
- alias("com.intellij.platform.ide.provisioner")
-
- deprecatedInclude("intellij.java.ide.resources", "META-INF/JavaIdePlugin.xml")
- deprecatedInclude("intellij.idea.community.customization", "META-INF/tips-intellij-idea-community.xml")
-
- moduleSet(CommunityModuleSets.debuggerStreams())
-
- module("intellij.platform.coverage")
- module("intellij.platform.coverage.agent")
- module("intellij.xml.xmlbeans")
- module("intellij.platform.ide.newUiOnboarding")
- module("intellij.platform.ide.newUsersOnboarding")
- module("intellij.ide.startup.importSettings")
- module("intellij.platform.customization.min")
- module("intellij.idea.customization.base")
- module("intellij.idea.customization.backend")
- module("intellij.platform.tips")
-
- moduleSet(CommunityModuleSets.ideCommon())
- moduleSet(CommunityModuleSets.rdCommon())
-
- if (includeCommunityExtensions) {
- deprecatedInclude("intellij.platform.extended.community.impl", "META-INF/community-extensions.xml", ultimateOnly = true)
- }
- deprecatedInclude("intellij.idea.community.customization", "META-INF/community-customization.xml")
- }
-
- override suspend fun copyAdditionalFiles(context: BuildContext, targetDir: Path) {
- super.copyAdditionalFiles(context, targetDir)
+ override suspend fun copyAdditionalFiles(targetDir: Path, context: BuildContext) {
+ super.copyAdditionalFiles(targetDir, context)
copyFileToDir(context.paths.communityHomeDir.resolve("LICENSE.txt"), targetDir)
copyFileToDir(context.paths.communityHomeDir.resolve("NOTICE.txt"), targetDir)
@@ -211,12 +177,12 @@ open class IdeaCommunityProperties(private val communityHomeDir: Path) : JetBrai
override fun getRootDirectoryName(appInfo: ApplicationInfoProperties, buildNumber: String): String = "idea-IC-$buildNumber"
override fun generateExecutableFilesPatterns(
- context: BuildContext,
includeRuntime: Boolean,
arch: JvmArchitecture,
targetLibcImpl: LibcImpl,
+ context: BuildContext,
): Sequence {
- return super.generateExecutableFilesPatterns(context, includeRuntime, arch, targetLibcImpl)
+ return super.generateExecutableFilesPatterns(includeRuntime, arch, targetLibcImpl, context)
.plus(KotlinBinaries.kotlinCompilerExecutables)
.filterNot { it == "plugins/**/*.sh" }
}
@@ -242,8 +208,8 @@ open class IdeaCommunityProperties(private val communityHomeDir: Path) : JetBrai
}
}
- override fun generateExecutableFilesPatterns(context: BuildContext, includeRuntime: Boolean, arch: JvmArchitecture): Sequence {
- return super.generateExecutableFilesPatterns(context, includeRuntime, arch)
+ override fun generateExecutableFilesPatterns(includeRuntime: Boolean, arch: JvmArchitecture, context: BuildContext): Sequence {
+ return super.generateExecutableFilesPatterns(includeRuntime, arch, context)
.plus(KotlinBinaries.kotlinCompilerExecutables)
.filterNot { it == "plugins/**/*.sh" }
}
@@ -257,3 +223,45 @@ open class IdeaCommunityProperties(private val communityHomeDir: Path) : JetBrai
override fun getOutputDirectoryName(appInfo: ApplicationInfoProperties): String = "idea-ce"
}
+
+/**
+ * Base IntelliJ Community content fragment.
+ * This fragment is composable - subclasses can include this and optionally add community extensions.
+ */
+fun intellijCommunityBaseFragment(): ProductModulesContentSpec = productModules {
+ alias("com.intellij.modules.idea")
+ alias("com.intellij.modules.idea.community")
+ alias("com.intellij.modules.java-capable")
+ alias("com.intellij.modules.python-core-capable")
+ alias("com.intellij.modules.python-in-non-pycharm-ide-capable")
+ alias("com.intellij.platform.ide.provisioner")
+
+ deprecatedInclude("intellij.java.ide.resources", "META-INF/JavaIdePlugin.xml")
+ deprecatedInclude("intellij.idea.community.customization", "META-INF/tips-intellij-idea-community.xml")
+
+ moduleSet(CommunityModuleSets.debuggerStreams())
+
+ module("intellij.platform.coverage")
+ module("intellij.platform.coverage.agent")
+ module("intellij.xml.xmlbeans")
+ module("intellij.platform.ide.newUiOnboarding")
+ module("intellij.platform.ide.newUsersOnboarding")
+ module("intellij.ide.startup.importSettings")
+ module("intellij.platform.customization.min")
+ module("intellij.idea.customization.base")
+ module("intellij.idea.customization.backend")
+ module("intellij.platform.tips")
+
+ moduleSet(CommunityModuleSets.ideCommon())
+ moduleSet(CommunityModuleSets.rdCommon())
+
+ deprecatedInclude("intellij.idea.community.customization", "META-INF/community-customization.xml")
+}
+
+/**
+ * Community extensions fragment for Ultimate builds.
+ * This fragment is composable - subclasses can choose to include or exclude it.
+ */
+fun communityExtensionsFragment(): ProductModulesContentSpec = productModules {
+ deprecatedInclude("intellij.platform.extended.community.impl", "META-INF/community-extensions.xml", ultimateOnly = true)
+}
\ No newline at end of file
diff --git a/community-resources/resources/META-INF/IdeaPlugin.xml b/community-resources/resources/META-INF/IdeaPlugin.xml
index 8dcc33330e24..7c30501c24ad 100644
--- a/community-resources/resources/META-INF/IdeaPlugin.xml
+++ b/community-resources/resources/META-INF/IdeaPlugin.xml
@@ -11,15 +11,15 @@
+
-
+
-
@@ -30,6 +30,6 @@
-
+
diff --git a/platform/build-scripts/BUILD.bazel b/platform/build-scripts/BUILD.bazel
index 641b69dcf636..c48885f64843 100644
--- a/platform/build-scripts/BUILD.bazel
+++ b/platform/build-scripts/BUILD.bazel
@@ -88,6 +88,12 @@ jvm_library(
"@lib//:platform-build_scripts-jetbrains-intellij-deps-coverage-reporter",
"//libraries/ktor/client",
"//platform/plugins/parser/impl",
+ "//platform/build-scripts/api",
+ "//platform/build-scripts/product-dsl",
+ ],
+ exports = [
+ "//platform/build-scripts/api",
+ "//platform/build-scripts/product-dsl",
],
runtime_deps = [
"//libraries/commons/cli",
diff --git a/platform/build-scripts/api/BUILD.bazel b/platform/build-scripts/api/BUILD.bazel
new file mode 100644
index 000000000000..e69e7c5f9296
--- /dev/null
+++ b/platform/build-scripts/api/BUILD.bazel
@@ -0,0 +1,21 @@
+### auto-generated section `build intellij.platform.buildScripts.api` start
+load("@rules_jvm//:jvm.bzl", "jvm_library")
+
+jvm_library(
+ name = "api",
+ module_name = "intellij.platform.buildScripts.api",
+ visibility = ["//visibility:public"],
+ srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True),
+ deps = [
+ "@lib//:kotlin-stdlib",
+ "//plugins/groovy/rt/classLoader",
+ "//platform/util-class-loader:util-classLoader",
+ "//platform/util",
+ "@lib//:jetbrains-annotations",
+ "//platform/build-scripts/downloader",
+ "//jps/model-api:model",
+ "//platform/util/zip",
+ "@community//build:zip",
+ ]
+)
+### auto-generated section `build intellij.platform.buildScripts.api` end
\ No newline at end of file
diff --git a/platform/build-scripts/api/intellij.platform.buildScripts.api.iml b/platform/build-scripts/api/intellij.platform.buildScripts.api.iml
new file mode 100644
index 000000000000..a8cd9306d3e4
--- /dev/null
+++ b/platform/build-scripts/api/intellij.platform.buildScripts.api.iml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/platform/build-scripts/src/org/jetbrains/intellij/build/BuildPaths.kt b/platform/build-scripts/api/src/BuildPaths.kt
similarity index 99%
rename from platform/build-scripts/src/org/jetbrains/intellij/build/BuildPaths.kt
rename to platform/build-scripts/api/src/BuildPaths.kt
index fe6450c1a4c3..bfee0992fcd0 100644
--- a/platform/build-scripts/src/org/jetbrains/intellij/build/BuildPaths.kt
+++ b/platform/build-scripts/api/src/BuildPaths.kt
@@ -72,4 +72,4 @@ class BuildPaths(
*/
@Deprecated("Use [artifactDir] or [tempDir] instead")
val jpsArtifacts: Path = buildOutputDir.resolve("jps-artifacts")
-}
+}
\ No newline at end of file
diff --git a/platform/build-scripts/src/org/jetbrains/intellij/build/IdeaProjectLoaderUtil.kt b/platform/build-scripts/api/src/IdeaProjectLoaderUtil.kt
similarity index 95%
rename from platform/build-scripts/src/org/jetbrains/intellij/build/IdeaProjectLoaderUtil.kt
rename to platform/build-scripts/api/src/IdeaProjectLoaderUtil.kt
index 5d988defd018..dcd5af382176 100644
--- a/platform/build-scripts/src/org/jetbrains/intellij/build/IdeaProjectLoaderUtil.kt
+++ b/platform/build-scripts/api/src/IdeaProjectLoaderUtil.kt
@@ -1,9 +1,8 @@
-// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.intellij.build
import com.intellij.openapi.application.PathManager
import com.intellij.util.lang.UrlClassLoader
-import org.jetbrains.annotations.ApiStatus
+import org.jetbrains.annotations.ApiStatus.Internal
import org.jetbrains.intellij.build.dependencies.BuildDependenciesCommunityRoot
import java.nio.file.Path
import java.nio.file.Paths
@@ -60,7 +59,7 @@ internal object IdeaProjectLoaderUtil {
/**
* This method only for internal usage. Use [BuildPaths.ULTIMATE_HOME] instead.
*/
- @ApiStatus.Internal
+ @Internal
fun guessUltimateHome(): Path {
return searchForAnyMarkerFile(listOf(ULTIMATE_REPO_MARKER_FILE))
}
@@ -68,7 +67,7 @@ internal object IdeaProjectLoaderUtil {
/**
* This method only for internal usage. Use [BuildPaths.COMMUNITY_ROOT] instead.
*/
- @ApiStatus.Internal
+ @Internal
fun guessCommunityHome(): BuildDependenciesCommunityRoot {
val directMarker = COMMUNITY_REPO_MARKER_FILE
val inSubdirMarker = Path.of("community").resolve(COMMUNITY_REPO_MARKER_FILE)
@@ -85,7 +84,7 @@ internal object IdeaProjectLoaderUtil {
/**
* This method only for internal usage. Use [BuildPaths.MAYBE_ULTIMATE_HOME] instead.
*/
- @ApiStatus.Internal
+ @Internal
fun maybeUltimateHome(): Path? {
return searchForOptionalMarkerFile(listOf(ULTIMATE_REPO_MARKER_FILE))
}
@@ -134,4 +133,4 @@ internal object IdeaProjectLoaderUtil {
checkNotNull(classFileURL) { "Could not get .class file location from class " + klass.getName() }
return UrlClassLoader.urlToFilePath(classFileURL.path)
}
-}
+}
\ No newline at end of file
diff --git a/platform/build-scripts/api/src/ModuleOutputProvider.kt b/platform/build-scripts/api/src/ModuleOutputProvider.kt
new file mode 100644
index 000000000000..8fa4026efe24
--- /dev/null
+++ b/platform/build-scripts/api/src/ModuleOutputProvider.kt
@@ -0,0 +1,15 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package org.jetbrains.intellij.build
+
+import org.jetbrains.jps.model.module.JpsModule
+import java.nio.file.Path
+
+interface ModuleOutputProvider {
+ fun readFileContentFromModuleOutput(module: JpsModule, relativePath: String, forTests: Boolean = false): ByteArray?
+
+ fun findModule(name: String): JpsModule?
+
+ fun findRequiredModule(name: String): JpsModule
+
+ fun getModuleOutputRoots(module: JpsModule, forTests: Boolean = false): List
+}
\ No newline at end of file
diff --git a/platform/build-scripts/src/org/jetbrains/intellij/build/moduleContentUtil.kt b/platform/build-scripts/api/src/moduleContentUtil.kt
similarity index 87%
rename from platform/build-scripts/src/org/jetbrains/intellij/build/moduleContentUtil.kt
rename to platform/build-scripts/api/src/moduleContentUtil.kt
index 559c71f70a29..810845a2c72a 100644
--- a/platform/build-scripts/src/org/jetbrains/intellij/build/moduleContentUtil.kt
+++ b/platform/build-scripts/api/src/moduleContentUtil.kt
@@ -3,8 +3,6 @@ package org.jetbrains.intellij.build
import com.intellij.util.lang.ImmutableZipFile
import org.jetbrains.annotations.ApiStatus.Internal
-import org.jetbrains.intellij.build.classPath.PLUGIN_XML_RELATIVE_PATH
-import org.jetbrains.intellij.build.impl.ModuleOutputProvider
import org.jetbrains.intellij.build.io.ZipEntryProcessorResult
import org.jetbrains.intellij.build.io.readZipFile
import org.jetbrains.jps.model.java.JavaResourceRootType
@@ -19,6 +17,9 @@ import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.BasicFileAttributes
+const val PLUGIN_XML_RELATIVE_PATH: String = "META-INF/plugin.xml"
+val useTestSourceEnabled: Boolean = System.getProperty("idea.build.pack.test.source.enabled", "true").toBoolean()
+
fun getUnprocessedPluginXmlContent(module: JpsModule, context: ModuleOutputProvider): ByteArray {
return requireNotNull(findUnprocessedDescriptorContent(module = module, path = PLUGIN_XML_RELATIVE_PATH, context = context)) {
"META-INF/plugin.xml not found in ${module.name} module output"
@@ -40,7 +41,7 @@ fun findUnprocessedDescriptorContent(module: JpsModule, path: String, context: M
private val rootTypeOrder = arrayOf(JavaResourceRootType.RESOURCE, JavaSourceRootType.SOURCE, JavaResourceRootType.TEST_RESOURCE, JavaSourceRootType.TEST_SOURCE)
-internal fun findFileInModuleSources(module: JpsModule, relativePath: String, onlyProductionSources: Boolean = false): Path? {
+fun findFileInModuleSources(module: JpsModule, relativePath: String, onlyProductionSources: Boolean = false): Path? {
for (type in rootTypeOrder) {
for (root in module.sourceRoots) {
if (type != root.rootType || (onlyProductionSources && !(root.rootType == JavaResourceRootType.RESOURCE || root.rootType == JavaSourceRootType.SOURCE))) {
@@ -55,9 +56,9 @@ internal fun findFileInModuleSources(module: JpsModule, relativePath: String, on
return null
}
-internal fun isModuleNameLikeFilename(relativePath: String): Boolean = relativePath.startsWith("intellij.") || relativePath.startsWith("fleet.")
+fun isModuleNameLikeFilename(relativePath: String): Boolean = relativePath.startsWith("intellij.") || relativePath.startsWith("fleet.")
-internal fun findFileInModuleLibraryDependencies(module: JpsModule, relativePath: String): ByteArray? {
+fun findFileInModuleLibraryDependencies(module: JpsModule, relativePath: String): ByteArray? {
for (dependency in module.dependenciesList.dependencies) {
if (dependency is JpsLibraryDependency) {
val library = dependency.library ?: continue
@@ -71,14 +72,14 @@ internal fun findFileInModuleLibraryDependencies(module: JpsModule, relativePath
return null
}
-internal fun findProductModulesFile(clientMainModuleName: String, context: CompilationContext): Path? {
+fun findProductModulesFile(clientMainModuleName: String, context: ModuleOutputProvider): Path? {
return findFileInModuleSources(context.findRequiredModule(clientMainModuleName), "META-INF/$clientMainModuleName/product-modules.xml")
}
-internal fun findFileInModuleDependencies(
+fun findFileInModuleDependencies(
module: JpsModule,
relativePath: String,
- context: CompilationContext,
+ context: ModuleOutputProvider,
processedModules: MutableSet,
recursiveModuleExclude: String? = null,
): ByteArray? {
@@ -98,7 +99,7 @@ internal fun findFileInModuleDependencies(
private fun findFileInModuleDependenciesRecursive(
module: JpsModule,
relativePath: String,
- context: CompilationContext,
+ context: ModuleOutputProvider,
processedModules: MutableSet,
recursiveModuleExclude: String?,
): ByteArray? {
@@ -134,7 +135,7 @@ private fun findFileInModuleDependenciesRecursive(
}
@Internal
-fun hasModuleOutputPath(module: JpsModule, relativePath: String, context: CompilationContext): Boolean {
+fun hasModuleOutputPath(module: JpsModule, relativePath: String, context: ModuleOutputProvider): Boolean {
return context.getModuleOutputRoots(module).any { output ->
val attributes = try {
Files.readAttributes(output, BasicFileAttributes::class.java)
diff --git a/platform/build-scripts/intellij.platform.buildScripts.iml b/platform/build-scripts/intellij.platform.buildScripts.iml
index cf05b5bc6997..5a952ab749dc 100644
--- a/platform/build-scripts/intellij.platform.buildScripts.iml
+++ b/platform/build-scripts/intellij.platform.buildScripts.iml
@@ -420,5 +420,7 @@
+
+
\ No newline at end of file
diff --git a/platform/build-scripts/product-dsl/BUILD.bazel b/platform/build-scripts/product-dsl/BUILD.bazel
new file mode 100644
index 000000000000..58ed9f5c6c15
--- /dev/null
+++ b/platform/build-scripts/product-dsl/BUILD.bazel
@@ -0,0 +1,65 @@
+### auto-generated section `build intellij.platform.buildScripts.productDsl` start
+load("//build:compiler-options.bzl", "create_kotlinc_options")
+load("@rules_jvm//:jvm.bzl", "jvm_library")
+
+create_kotlinc_options(
+ name = "custom_product-dsl",
+ opt_in = [
+ "kotlin.RequiresOptIn",
+ "kotlinx.serialization.ExperimentalSerializationApi",
+ "kotlinx.coroutines.ExperimentalCoroutinesApi",
+ ]
+)
+
+jvm_library(
+ name = "product-dsl",
+ module_name = "intellij.platform.buildScripts.productDsl",
+ visibility = ["//visibility:public"],
+ srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True),
+ kotlinc_opts = ":custom_product-dsl",
+ deps = [
+ "@lib//:kotlin-stdlib",
+ "//platform/plugins/parser/impl",
+ "//platform/build-scripts/api",
+ "//libraries/kotlinx/serialization/core",
+ "//libraries/kotlinx/serialization/json",
+ "//jps/model-api:model",
+ "//jps/model-serialization",
+ "//platform/util",
+ "//platform/util/jdom",
+ "//libraries/jackson/jackson",
+ ]
+)
+
+jvm_library(
+ name = "product-dsl_test_lib",
+ visibility = ["//visibility:public"],
+ srcs = glob(["testSrc/**/*.kt", "testSrc/**/*.java", "testSrc/**/*.form"], allow_empty = True),
+ kotlinc_opts = ":custom_product-dsl",
+ associates = [":product-dsl"],
+ deps = [
+ "@lib//:kotlin-stdlib",
+ "//platform/plugins/parser/impl",
+ "//platform/plugins/parser/impl:impl_test_lib",
+ "//platform/build-scripts/api",
+ "//libraries/kotlinx/serialization/core",
+ "//libraries/kotlinx/serialization/json",
+ "//jps/model-api:model",
+ "//jps/model-serialization",
+ "//platform/util",
+ "//platform/util/jdom",
+ "@lib//:junit5",
+ "@lib//:assert_j",
+ "//libraries/jackson/jackson",
+ ]
+)
+### auto-generated section `build intellij.platform.buildScripts.productDsl` end
+
+### auto-generated section `test intellij.platform.buildScripts.productDsl` start
+load("@community//build:tests-options.bzl", "jps_test")
+
+jps_test(
+ name = "product-dsl_test",
+ runtime_deps = [":product-dsl_test_lib"]
+)
+### auto-generated section `test intellij.platform.buildScripts.productDsl` end
\ No newline at end of file
diff --git a/platform/build-scripts/product-dsl/intellij.platform.buildScripts.productDsl.iml b/platform/build-scripts/product-dsl/intellij.platform.buildScripts.productDsl.iml
new file mode 100644
index 000000000000..1ade7fd58b9b
--- /dev/null
+++ b/platform/build-scripts/product-dsl/intellij.platform.buildScripts.productDsl.iml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $KOTLIN_BUNDLED$/lib/kotlinx-serialization-compiler-plugin.jar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/platform/build-scripts/product-dsl/module-sets.md b/platform/build-scripts/product-dsl/module-sets.md
new file mode 100644
index 000000000000..e29c94c9033e
--- /dev/null
+++ b/platform/build-scripts/product-dsl/module-sets.md
@@ -0,0 +1,56 @@
+# Module Sets
+
+Module sets are collections of modules that can be referenced as a single entity in product configurations.
+
+## Overview
+
+Module sets are now **defined in Kotlin code** in the following locations:
+- **Community module sets**: `community/platform/build-scripts/product-dsl/src/CommunityModuleSets.kt`
+- **Ultimate module sets**: `platform/buildScripts/src/productLayout/UltimateModuleSets.kt`
+
+Each module set is defined as a Kotlin function returning a `ModuleSet` object. The XML files (following the pattern `intellij.moduleSets...xml`) are **auto-generated** from this Kotlin code.
+
+### Generating XML Files
+
+To regenerate XML files from Kotlin code, run:
+```bash
+# For all products (community + ultimate)
+UltimateModuleSets.main()
+
+# For community products only
+CommunityModuleSets.main()
+```
+
+Or use the IDE's "Generate Product Layouts" run configuration.
+
+### Querying Module Sets
+
+To query module set structure, relationships, and usage programmatically, use the **JSON analysis endpoint**:
+```bash
+UltimateModuleSets.main(args = ["--json"])
+```
+
+See the [Programmatic Content](programmatic-content.md#json-analysis-endpoint) documentation for details.
+
+## Creating a New Module Set
+
+See `/create-module-set` slash command for detailed instructions on creating a new module set.
+
+## Available Module Sets
+
+Module sets are defined in Kotlin code:
+
+- **Community module sets**: See `community/platform/build-scripts/product-dsl/src/CommunityModuleSets.kt`
+- **Ultimate module sets**: See `platform/buildScripts/src/productLayout/UltimateModuleSets.kt`
+
+Each module set is defined as a function (e.g., `fun essential(): ModuleSet`) within these classes.
+
+### Querying Module Sets
+
+To discover available module sets and their composition:
+
+1. **Browse the source code** - Open the Kotlin files listed above to see all available module sets
+2. **Use the JSON endpoint** - Run `UltimateModuleSets.main(args = ["--json"])` to get comprehensive analysis
+3. **Check generated XML** - The generated XML files (e.g., `intellij.moduleSets.essential.xml`) contain expanded module lists
+
+For detailed module composition, includes/includedBy relationships, and product usage, use the [JSON analysis endpoint](#querying-module-sets).
\ No newline at end of file
diff --git a/platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/programmatic-content.md b/platform/build-scripts/product-dsl/programmatic-content.md
similarity index 71%
rename from platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/programmatic-content.md
rename to platform/build-scripts/product-dsl/programmatic-content.md
index 4a682ff95b73..7727da4e8f2a 100644
--- a/platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/programmatic-content.md
+++ b/platform/build-scripts/product-dsl/programmatic-content.md
@@ -283,6 +283,173 @@ Use `ultimateOnly = true` when:
- **`generateAllProductXmlFiles()`** (generator.kt): Batch generation for all registered products
- **`collectAndValidateAliases()`** (generator.kt): Validates module aliases for duplicates
+## JSON Analysis Endpoint
+
+The module set system provides a JSON analysis endpoint for programmatic querying and tooling integration. This endpoint is used by the Plugin Model Analyzer MCP server and other build tools.
+
+### Usage
+
+Run the module set main function with the `--json` flag:
+
+```bash
+# Generate complete analysis for all products and module sets
+UltimateModuleSets.main(args = ["--json"])
+
+# Community products only
+CommunityModuleSets.main(args = ["--json"])
+```
+
+### Filtering Output
+
+Use the `--json` flag with a filter to get specific sections:
+
+```bash
+# Get only products
+--json='{"filter":"products"}'
+
+# Get only module sets
+--json='{"filter":"moduleSets"}'
+
+# Include duplicate analysis
+--json='{"includeDuplicates":true}'
+```
+
+### Output Structure
+
+The JSON output contains comprehensive analysis of the module system:
+
+#### 1. Module Distribution
+
+Maps each module to the module sets and products that include it:
+
+```json
+{
+ "moduleDistribution": {
+ "intellij.platform.vcs.impl": {
+ "inModuleSets": ["vcs", "ide.common"],
+ "inProducts": ["WebStorm", "GoLand", "CLion", "PyCharm", ...]
+ }
+ }
+}
+```
+
+**Use case:** Find where a specific module is used across the codebase.
+
+#### 2. Module Set Hierarchy
+
+Shows the include relationships between module sets:
+
+```json
+{
+ "moduleSetHierarchy": {
+ "ide.common": {
+ "includes": ["essential", "vcs"],
+ "includedBy": ["ide.ultimate"],
+ "moduleCount": 145
+ }
+ }
+}
+```
+
+**Use case:** Understand module set dependencies and nesting structure.
+
+#### 3. Module Usage Index
+
+Comprehensive reverse lookup with source file paths:
+
+```json
+{
+ "moduleUsageIndex": {
+ "modules": {
+ "intellij.platform.vcs.impl": {
+ "moduleSets": [
+ {
+ "name": "vcs",
+ "location": "community",
+ "sourceFile": "community/platform/build-scripts/product-dsl/src/CommunityModuleSets.kt"
+ }
+ ],
+ "products": [
+ {
+ "name": "WebStorm",
+ "sourceFile": "platform/buildScripts/src/productLayout/UltimateModuleSets.kt"
+ }
+ ]
+ }
+ }
+ }
+}
+```
+
+**Use case:** Trace module ownership and find where to make changes.
+
+#### 4. Product Composition Analysis
+
+Detailed breakdown of each product's composition:
+
+```json
+{
+ "productCompositionAnalysis": {
+ "CLion": {
+ "composition": {
+ "totalAliases": 3,
+ "totalModuleSets": 12,
+ "totalDirectModules": 45,
+ "totalModules": 523
+ },
+ "operations": [
+ {"type": "alias", "value": "com.jetbrains.modules.cidr.lang"},
+ {"type": "moduleSet", "value": "commercial"},
+ {"type": "module", "value": "intellij.clion.core"}
+ ]
+ }
+ }
+}
+```
+
+**Use case:** Analyze product composition and optimize module dependencies.
+
+#### 5. Duplicate Analysis (Optional)
+
+When `includeDuplicates: true` is set, detects duplicate xi:include elements:
+
+```json
+{
+ "duplicateAnalysis": {
+ "ReSharper Backend": {
+ "/META-INF/intellij.moduleSets.essential.xml": [
+ {
+ "directInclude": true,
+ "deprecatedIncludeRefs": [
+ "intellij.platform.resources -> /META-INF/PlatformLangPlugin.xml"
+ ]
+ }
+ ]
+ }
+ }
+}
+```
+
+**Use case:** Identify redundant includes that can be removed.
+
+### Integration with MCP Server
+
+The Plugin Model Analyzer MCP server (`build/mcp-servers/module-analyzer`) uses this JSON endpoint to provide:
+
+- `analyze_module_structure` - Complete module system analysis
+- `get_module_info` - Query specific module details
+- `find_module_paths` - Trace module to product paths
+- `get_module_set_hierarchy` - Query module set relationships
+- `list_products` - List products filtered by criteria
+- `validate_community_products` - Ensure community/ultimate separation
+
+### Implementation
+
+The JSON generation is implemented in:
+- `ModuleSetRunner.kt` - Orchestration and CLI parsing
+- `ModuleSetJsonExport.kt` - JSON generation logic
+- `ModuleSetDiscovery.kt` - Module set discovery via reflection
+
## Benefits
1. **Type safety**: Kotlin code with IDE support (autocomplete, refactoring)
@@ -290,6 +457,7 @@ Use `ultimateOnly = true` when:
3. **Single source of truth**: One Kotlin definition for both dev and non-dev modes
4. **Maintainability**: Easier to see what modules a product includes
5. **VCS-friendly**: Static files work without dev mode infrastructure
+6. **Programmatic access**: JSON endpoint enables tooling and automation
## See Also
diff --git a/platform/build-scripts/product-dsl/src/ContentBlockBuilder.kt b/platform/build-scripts/product-dsl/src/ContentBlockBuilder.kt
new file mode 100644
index 000000000000..2df37a088c9a
--- /dev/null
+++ b/platform/build-scripts/product-dsl/src/ContentBlockBuilder.kt
@@ -0,0 +1,152 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+@file:Suppress("ReplaceGetOrSet")
+
+package org.jetbrains.intellij.build.productLayout
+
+import com.intellij.platform.plugins.parser.impl.elements.ModuleLoadingRule
+
+/**
+ * Builds content blocks and module-to-set chain mapping in a single hierarchical traversal.
+ * This optimized version eliminates redundant tree walking by computing all results simultaneously.
+ *
+ * @param spec The product modules specification
+ * @param collectModuleSetAliases Whether to collect module set aliases during traversal (for inlineModuleSets mode)
+ * @return Triple of (content blocks, module-to-set chain mapping, module set alias to source mapping)
+ */
+internal fun buildContentBlocksAndChainMapping(
+ spec: ProductModulesContentSpec,
+ collectModuleSetAliases: Boolean = false
+): Triple, Map>, Map> {
+ val contentBlocks = mutableListOf()
+ val moduleToChain = mutableMapOf>()
+ val moduleToSets = mutableMapOf>()
+ val aliasToSource = if (collectModuleSetAliases) mutableMapOf() else null
+ val processedSets = HashSet()
+ val contentBlockByName = mutableMapOf()
+
+ fun traverse(moduleSet: ModuleSet, chain: List, overrides: Map) {
+ val setName = "$MODULE_SET_PREFIX${moduleSet.name}"
+
+ // Check if already processed
+ val alreadyProcessed = !processedSets.add(setName)
+ if (alreadyProcessed) {
+ // If already processed but now we have overrides, update the existing content block
+ if (overrides.isNotEmpty()) {
+ val existingBlock = contentBlockByName[moduleSet.name]
+ if (existingBlock != null) {
+ // Check if existing block has any loading attributes
+ val hasExistingOverrides = existingBlock.modules.any { it.loading != null }
+ if (!hasExistingOverrides) {
+ // Reuse the filtered module list from existing block (already filtered by first pass)
+ val updatedModules = mutableListOf()
+ for (existingModule in existingBlock.modules) {
+ val effectiveLoading = overrides[existingModule.name] ?: existingModule.loading
+ updatedModules.add(ModuleWithLoading(existingModule.name, effectiveLoading))
+ }
+
+ // Create new content block with overrides and replace the old one
+ val updatedBlock = ContentBlock(moduleSet.name, updatedModules)
+ val oldBlockIndex = contentBlocks.indexOf(existingBlock)
+ if (oldBlockIndex >= 0) {
+ contentBlocks[oldBlockIndex] = updatedBlock
+ contentBlockByName[moduleSet.name] = updatedBlock
+ }
+ // Don't re-add to moduleToSets or moduleToChain - already there from first pass
+ }
+ // else: Both have overrides, keep the first one (shouldn't happen in practice)
+ }
+ }
+ // Already processed, don't reprocess
+ return
+ }
+ // Collect module set alias if requested
+ if (aliasToSource != null && moduleSet.alias != null) {
+ validateAndRecordAlias(
+ alias = moduleSet.alias,
+ source = "module set '${moduleSet.name}'",
+ aliasToSource = aliasToSource
+ )
+ }
+
+ val currentChain = chain + setName
+
+ // Get direct modules for this set
+ val directModules = getDirectModules(moduleSet, spec.excludedModules)
+
+ // Build content block and track chains/duplicates in single pass
+ val modulesWithLoading = mutableListOf()
+ for (module in directModules) {
+ // Track for duplicate detection
+ moduleToSets.computeIfAbsent(module.name) { mutableListOf() }.add(moduleSet.name)
+ // Track chain
+ moduleToChain[module.name] = currentChain
+ // Build loading info - apply overrides from module set
+ val effectiveLoading = overrides[module.name] ?: module.loading
+ modulesWithLoading.add(ModuleWithLoading(module.name, effectiveLoading))
+ }
+
+ if (modulesWithLoading.isNotEmpty()) {
+ val block = ContentBlock(moduleSet.name, modulesWithLoading)
+ contentBlocks.add(block)
+ contentBlockByName[moduleSet.name] = block
+ }
+
+ // Recursively process nested sets (no override cascading - each set must be referenced directly for overrides)
+ for (nestedSet in moduleSet.nestedSets) {
+ traverse(nestedSet, currentChain, emptyMap())
+ }
+ }
+
+ // Process all top-level module sets
+ for (moduleSetWithOverrides in spec.moduleSets) {
+ traverse(moduleSetWithOverrides.moduleSet, emptyList(), moduleSetWithOverrides.loadingOverrides)
+ }
+
+ // Validate that all overridden modules exist as direct modules in their respective module sets
+ for (moduleSetWithOverrides in spec.moduleSets) {
+ validateModuleSetOverrides(moduleSetWithOverrides, spec)
+ }
+
+ // Check for duplicates and FAIL if found
+ validateNoDuplicateModules(moduleToSets)
+
+ // Add additional modules if any
+ val additionalModulesWithLoading = mutableListOf()
+ for (module in spec.additionalModules) {
+ if (module.name !in spec.excludedModules) {
+ additionalModulesWithLoading.add(ModuleWithLoading(module.name, module.loading))
+ }
+ }
+
+ if (additionalModulesWithLoading.isNotEmpty()) {
+ contentBlocks.add(ContentBlock(ADDITIONAL_MODULES_BLOCK, additionalModulesWithLoading))
+ }
+
+ return Triple(contentBlocks, moduleToChain, aliasToSource ?: emptyMap())
+}
+
+/**
+ * Collects and validates product-level aliases and merges with module set aliases.
+ * Checks for duplicates between product-level and module set aliases.
+ *
+ * @param spec The product modules specification
+ * @param moduleSetAliases Aliases collected from module sets during traversal
+ * @return List of validated unique aliases
+ */
+internal fun collectAndValidateAliases(
+ spec: ProductModulesContentSpec,
+ moduleSetAliases: Map
+): List {
+ val allAliases = moduleSetAliases.toMutableMap()
+
+ // Collect product-level aliases and check for conflicts with module set aliases
+ for (alias in spec.productModuleAliases) {
+ validateAndRecordAlias(
+ alias = alias,
+ source = "product level",
+ aliasToSource = allAliases
+ )
+ }
+
+ return allAliases.keys.sorted()
+}
diff --git a/platform/build-scripts/product-dsl/src/DuplicateIncludeDetector.kt b/platform/build-scripts/product-dsl/src/DuplicateIncludeDetector.kt
new file mode 100644
index 000000000000..8385d0fd31e0
--- /dev/null
+++ b/platform/build-scripts/product-dsl/src/DuplicateIncludeDetector.kt
@@ -0,0 +1,288 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package org.jetbrains.intellij.build.productLayout
+
+import com.intellij.openapi.util.JDOMUtil
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import org.jdom.Element
+import java.nio.file.Path
+import kotlin.io.path.exists
+import kotlin.io.path.isRegularFile
+import kotlin.io.path.readText
+
+/**
+ * Detects duplicate xi:include references in product plugin.xml files.
+ * Analyzes both direct xi:includes and nested includes from deprecatedInclude XML files.
+ */
+object DuplicateIncludeDetector {
+
+ /**
+ * Detects duplicate xi:include references across all provided product XML files.
+ *
+ * @param productXmlFiles List of product plugin.xml file paths to analyze
+ * @param projectRoot Project root directory for resolving relative paths
+ * @return Report containing all detected duplicates
+ */
+ fun detectDuplicates(
+ productXmlFiles: List,
+ projectRoot: Path
+ ): DuplicateIncludesReport {
+ val productsWithDuplicates = mutableListOf()
+
+ for (productFile in productXmlFiles) {
+ if (!productFile.exists() || !productFile.isRegularFile()) {
+ continue
+ }
+
+ val allIncludes = mutableListOf>()
+
+ // 1. Parse direct xi:includes from product file
+ val directIncludes = parseXiIncludes(productFile)
+ for (include in directIncludes) {
+ allIncludes.add(include.href to IncludeSource(
+ file = productFile.toString(),
+ sourceType = "xi:include",
+ lineNumber = include.lineNumber
+ ))
+
+ // 2. If this xi:include points to a potential deprecatedInclude XML file, parse it too
+ val resolvedPath = resolveIncludePath(include.href, productFile, projectRoot)
+ if (resolvedPath != null && resolvedPath.exists() && isDeprecatedIncludeFile(resolvedPath)) {
+ val nestedIncludes = parseXiIncludes(resolvedPath)
+ for (nested in nestedIncludes) {
+ allIncludes.add(nested.href to IncludeSource(
+ file = resolvedPath.toString(),
+ sourceType = "deprecatedInclude-nested",
+ lineNumber = nested.lineNumber
+ ))
+ }
+ }
+ }
+
+ // 3. Find duplicates
+ val grouped = allIncludes.groupBy { it.first }
+ val duplicates = grouped.filter { it.value.size > 1 }.map { (href, sources) ->
+ DuplicateInclude(
+ href = href,
+ resolvedPath = resolveIncludePath(href, productFile, projectRoot)?.toString(),
+ count = sources.size,
+ sources = sources.map { it.second }
+ )
+ }
+
+ if (duplicates.isNotEmpty()) {
+ productsWithDuplicates.add(ProductDuplicates(
+ name = productFile.fileName.toString().removeSuffix(".xml"),
+ file = productFile.toString(),
+ duplicates = duplicates
+ ))
+ }
+ }
+
+ return DuplicateIncludesReport(
+ timestamp = System.currentTimeMillis().toString(),
+ products = productsWithDuplicates,
+ summary = DuplicateSummary(
+ totalProducts = productXmlFiles.size,
+ productsWithDuplicates = productsWithDuplicates.size,
+ totalDuplicateFiles = productsWithDuplicates.sumOf { it.duplicates.size }
+ )
+ )
+ }
+
+ /**
+ * Parses xi:include elements from an XML file.
+ * Returns list of XiInclude objects with href and line number.
+ */
+ private fun parseXiIncludes(xmlFile: Path): List {
+ val includes = mutableListOf()
+
+ try {
+ val content = xmlFile.readText()
+ val root = JDOMUtil.load(xmlFile)
+
+ // Find all xi:include elements (recursively)
+ val includeElements = findElementsByName(root, "include")
+ for (element in includeElements) {
+ val href = element.getAttributeValue("href")
+ if (!href.isNullOrEmpty()) {
+ // Try to find line number by searching in the file content
+ val lineNumber = findLineNumber(content, href)
+ includes.add(XiInclude(href, lineNumber))
+ }
+ }
+ }
+ catch (e: Exception) {
+ // Ignore parsing errors
+ }
+
+ return includes
+ }
+
+ /**
+ * Recursively finds all elements with the given local name (ignoring namespace).
+ */
+ private fun findElementsByName(element: Element, localName: String): List {
+ val result = mutableListOf()
+
+ // Check if current element matches (ignoring namespace prefix)
+ if (element.name == localName || element.name.endsWith(":$localName")) {
+ result.add(element)
+ }
+
+ // Recursively search children
+ for (child in element.children) {
+ result.addAll(findElementsByName(child, localName))
+ }
+
+ return result
+ }
+
+ /**
+ * Attempts to find the line number where an href appears in the XML content.
+ */
+ private fun findLineNumber(content: String, href: String): Int? {
+ val lines = content.lines()
+ for ((index, line) in lines.withIndex()) {
+ if (line.contains("href=\"$href\"")) {
+ return index + 1 // 1-based line numbers
+ }
+ }
+ return null
+ }
+
+ /**
+ * Resolves an xi:include href to an absolute file path.
+ */
+ private fun resolveIncludePath(href: String, currentFile: Path, projectRoot: Path): Path? {
+ if (href.startsWith("/META-INF/")) {
+ // Absolute path - search in known resource locations
+ val locations = listOf(
+ projectRoot.resolve("community/platform/platform-resources/src$href"),
+ projectRoot.resolve("community/platform/platform-resources/generated$href"),
+ projectRoot.resolve("community/java/ide-resources/resources$href"),
+ projectRoot.resolve("ultimate/platform-ultimate/resources$href"),
+ projectRoot.resolve("licenseCommon/resources$href"),
+ projectRoot.resolve("licenseCommon/generated$href"),
+ // Also check CIDR and other products
+ projectRoot.resolve("CIDR/clion/main/nolang/resources$href"),
+ projectRoot.resolve("goland/resources$href"),
+ projectRoot.resolve("ruby/resources$href"),
+ projectRoot.resolve("WebStorm/resources$href"),
+ projectRoot.resolve("dbe/ide/resources$href"),
+ projectRoot.resolve("aqua/branding/resources$href"),
+ projectRoot.resolve("rider/resources$href"),
+ )
+
+ for (loc in locations) {
+ if (loc.exists()) {
+ return loc
+ }
+ }
+ return null
+ }
+ else if (href.startsWith("/")) {
+ // Handle paths like /something.xml
+ return resolveIncludePath("/META-INF$href", currentFile, projectRoot)
+ }
+ else {
+ // Relative path
+ val dir = currentFile.parent
+ return dir?.resolve(href)
+ }
+ }
+
+ /**
+ * Checks if the given path is a deprecatedInclude XML file that might contain nested xi:includes.
+ */
+ private fun isDeprecatedIncludeFile(path: Path): Boolean {
+ val fileName = path.fileName.toString()
+ return fileName.endsWith("-customization.xml") ||
+ fileName == "ultimate.xml" ||
+ fileName == "PlatformLangPlugin.xml" ||
+ fileName == "JavaIdePlugin.xml" ||
+ fileName == "structuralsearch.xml" ||
+ fileName.endsWith("Plugin.xml")
+ }
+
+ /**
+ * Represents a single xi:include element.
+ */
+ private data class XiInclude(
+ val href: String,
+ val lineNumber: Int?
+ )
+}
+
+/**
+ * Detects and prints duplicate xi:include elements in product plugin.xml files.
+ * This is a convenience function that takes discovered products and outputs JSON report.
+ *
+ * @param products List of discovered products with their plugin XML paths
+ * @param projectRoot Project root directory for resolving relative paths
+ */
+fun detectAndPrintDuplicateIncludes(products: List, projectRoot: Path) {
+ // Convert ProductSpec plugin paths to actual file paths
+ val productFiles = products
+ .mapNotNull { it.pluginXmlPath }
+ .map { projectRoot.resolve(it) }
+ .filter { it.exists() && it.isRegularFile() }
+
+ // Run detection
+ val report = DuplicateIncludeDetector.detectDuplicates(productFiles, projectRoot)
+
+ // Output JSON
+ val json = Json { prettyPrint = true }
+ println(json.encodeToString(report))
+}
+
+/**
+ * Report containing all detected duplicate includes.
+ */
+@Serializable
+data class DuplicateIncludesReport(
+ val timestamp: String,
+ val products: List,
+ val summary: DuplicateSummary
+)
+
+/**
+ * Duplicates found in a single product.
+ */
+@Serializable
+data class ProductDuplicates(
+ val name: String,
+ val file: String,
+ val duplicates: List
+)
+
+/**
+ * A single duplicate include with its sources.
+ */
+@Serializable
+data class DuplicateInclude(
+ val href: String,
+ val resolvedPath: String?,
+ val count: Int,
+ val sources: List
+)
+
+/**
+ * Source of an include (where it was found).
+ */
+@Serializable
+data class IncludeSource(
+ val file: String,
+ val sourceType: String, // "xi:include" or "deprecatedInclude-nested"
+ val lineNumber: Int?
+)
+
+/**
+ * Summary statistics.
+ */
+@Serializable
+data class DuplicateSummary(
+ val totalProducts: Int,
+ val productsWithDuplicates: Int,
+ val totalDuplicateFiles: Int
+)
diff --git a/platform/build-scripts/product-dsl/src/GeneratorModel.kt b/platform/build-scripts/product-dsl/src/GeneratorModel.kt
new file mode 100644
index 000000000000..2be7149b58d8
--- /dev/null
+++ b/platform/build-scripts/product-dsl/src/GeneratorModel.kt
@@ -0,0 +1,64 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+@file:Suppress("ReplaceGetOrSet")
+
+package org.jetbrains.intellij.build.productLayout
+
+import com.intellij.platform.plugins.parser.impl.elements.ModuleLoadingRule
+
+// Constants for magic strings used throughout the generator
+internal const val ADDITIONAL_MODULES_BLOCK = "additional"
+internal const val JETBRAINS_NAMESPACE = "jetbrains"
+internal const val GENERATOR_SUFFIX = ".main()"
+internal const val MODULE_SET_PREFIX = "intellij.moduleSets."
+
+/**
+ * Type-safe wrapper for module set names.
+ * Prevents accidental mixing of module set names with other string types.
+ */
+@JvmInline
+value class ModuleSetName(val value: String) {
+ override fun toString(): String = value
+}
+
+/**
+ * Represents a single content block in a product plugin.xml.
+ * Each block corresponds to a module set or additional modules section.
+ */
+data class ContentBlock(
+ /** Source identifier for the block (e.g., "essential", "vcs", "additional") */
+ @JvmField val source: String,
+ /** List of modules with their effective loading modes */
+ @JvmField val modules: List,
+)
+
+/**
+ * A module with its effective loading mode after applying overrides and exclusions.
+ */
+data class ModuleWithLoading(
+ /** Module name */
+ @JvmField val name: String,
+ /** Effective loading mode (null means default/no attribute) */
+ @JvmField val loading: ModuleLoadingRule?,
+)
+
+/**
+ * Result of building product content XML.
+ * Contains the generated XML string, content blocks, and module-to-set chain mapping.
+ */
+data class ProductContentBuildResult(
+ /** Generated XML content as string */
+ @JvmField val xml: String,
+ /** List of content blocks generated from the spec */
+ @JvmField val contentBlocks: List,
+ /** Mapping from module name to its module set chain as list (e.g., ["parent", "child"]) */
+ @JvmField val moduleToSetChainMapping: Map>,
+)
+
+/**
+ * Result of building module set XML.
+ * Contains the XML string and count of direct modules (excluding nested).
+ */
+internal data class ModuleSetBuildResult(
+ @JvmField val xml: String,
+ @JvmField val directModuleCount: Int,
+)
\ No newline at end of file
diff --git a/platform/build-scripts/product-dsl/src/ModuleLoadingOverrideBuilder.kt b/platform/build-scripts/product-dsl/src/ModuleLoadingOverrideBuilder.kt
new file mode 100644
index 000000000000..552be24afb24
--- /dev/null
+++ b/platform/build-scripts/product-dsl/src/ModuleLoadingOverrideBuilder.kt
@@ -0,0 +1,55 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+@file:Suppress("ReplacePutWithAssignment")
+
+package org.jetbrains.intellij.build.productLayout
+
+import com.intellij.platform.plugins.parser.impl.elements.ModuleLoadingRule
+import kotlinx.serialization.Serializable
+
+/**
+ * Wrapper for a module set with optional loading overrides.
+ * When overrides are present, the module set will be inlined in the product XML
+ * instead of being referenced via xi:include.
+ */
+@Serializable
+data class ModuleSetWithOverrides(
+ @JvmField val moduleSet: ModuleSet,
+ @JvmField val loadingOverrides: Map = emptyMap(),
+) {
+ val hasOverrides: Boolean
+ get() = loadingOverrides.isNotEmpty()
+}
+
+/**
+ * DSL builder for module loading overrides within a module set.
+ */
+@ProductDslMarker
+class ModuleLoadingOverrideBuilder {
+ private val overrides = HashMap()
+
+ /**
+ * Override a module in this module set to be loaded as embedded (loading="embedded").
+ */
+ fun overrideAsEmbedded(moduleName: String) {
+ overrides.put(moduleName, ModuleLoadingRule.EMBEDDED)
+ }
+
+ /**
+ * Override a module in this module set to be loaded as required (loading="required").
+ */
+ fun overrideAsRequired(moduleName: String) {
+ overrides.put(moduleName, ModuleLoadingRule.REQUIRED)
+ }
+
+ /**
+ * Set custom loading rule for modules.
+ */
+ fun loading(rule: ModuleLoadingRule, vararg moduleNames: String) {
+ for (name in moduleNames) {
+ overrides.put(name, rule)
+ }
+ }
+
+ @PublishedApi
+ internal fun build(): Map = overrides.toMap()
+}
\ No newline at end of file
diff --git a/platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/ModuleSetBuilder.kt b/platform/build-scripts/product-dsl/src/ModuleSetBuilder.kt
similarity index 86%
rename from platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/ModuleSetBuilder.kt
rename to platform/build-scripts/product-dsl/src/ModuleSetBuilder.kt
index f739b3cbb440..5d811565ccae 100644
--- a/platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/ModuleSetBuilder.kt
+++ b/platform/build-scripts/product-dsl/src/ModuleSetBuilder.kt
@@ -2,12 +2,23 @@
package org.jetbrains.intellij.build.productLayout
import com.intellij.platform.plugins.parser.impl.elements.ModuleLoadingRule
+import kotlinx.serialization.Serializable
import org.jetbrains.intellij.build.BuildPaths
-import java.lang.invoke.MethodHandles
-import java.lang.invoke.MethodType
import java.nio.file.Files
import java.nio.file.Path
+/**
+ * Represents a content module with optional loading attribute.
+ *
+ * @param name Module name
+ * @param loading Optional loading mode (e.g., ModuleLoadingRule.EMBEDDED)
+ */
+@Serializable
+data class ContentModule(
+ @JvmField val name: String,
+ @JvmField val loading: ModuleLoadingRule? = null,
+)
+
/**
* Represents a named collection of content modules.
* The name serves as metadata for debugging and XML generation (used in the 'source' attribute).
@@ -17,6 +28,7 @@ import java.nio.file.Path
* @param nestedSets List of nested module sets (for xi:include generation)
* @param alias Optional module alias for `` declaration (e.g., "com.intellij.modules.xml")
*/
+@Serializable
data class ModuleSet(
@JvmField val name: String,
@JvmField val modules: List,
@@ -38,6 +50,7 @@ interface ModuleSetProvider {
/**
* DSL builder for creating ModuleSets with reduced boilerplate.
*/
+@ProductDslMarker
class ModuleSetBuilder {
private val modules = mutableListOf()
private val nestedSets = mutableListOf()
@@ -146,15 +159,6 @@ private fun appendModuleSetContent(sb: StringBuilder, moduleSet: ModuleSet, inde
}
}
-/**
- * Result of building module set XML.
- * Contains the XML string and count of direct modules (excluding nested).
- */
-data class ModuleSetBuildResult(
- val xml: String,
- val directModuleCount: Int,
-)
-
/**
* Builds the XML content for a module set.
*
@@ -202,32 +206,6 @@ internal fun buildModuleSetXml(moduleSet: ModuleSet, label: String): ModuleSetBu
return ModuleSetBuildResult(xml, directModuleCount)
}
-/**
- * Discovers all module set functions in the given object using reflection.
- * Returns all public functions that:
- * - Return ModuleSet
- * - Take no parameters
- * - Are not named 'main'
- *
- * @param obj The object to scan for module set functions (e.g., CommunityModuleSets, UltimateModuleSets)
- * @return List of all discovered ModuleSets
- */
-private fun discoverModuleSets(obj: Any): List {
- val lookup = MethodHandles.lookup()
- val clazz = obj.javaClass
- val methodType = MethodType.methodType(ModuleSet::class.java)
-
- val declaredMethods = clazz.declaredMethods
- val result = ArrayList(declaredMethods.size)
- for (method in declaredMethods) {
- if (method.parameterCount == 0 && java.lang.reflect.Modifier.isPublic(method.modifiers) && method.returnType == ModuleSet::class.java) {
- val moduleSet = lookup.findVirtual(clazz, method.name, methodType).invoke(obj) as ModuleSet
- result.add(moduleSet)
- }
- }
- return result
-}
-
/**
* Generates all module set XMLs for the given object.
* Discovers all ModuleSet functions via reflection, generates XML files, and prints results.
diff --git a/platform/build-scripts/product-dsl/src/ModuleSetDiscovery.kt b/platform/build-scripts/product-dsl/src/ModuleSetDiscovery.kt
new file mode 100644
index 000000000000..c0f8f82affbc
--- /dev/null
+++ b/platform/build-scripts/product-dsl/src/ModuleSetDiscovery.kt
@@ -0,0 +1,29 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package org.jetbrains.intellij.build.productLayout
+
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.MethodType
+
+/**
+ * Discovers all module sets from an object using reflection.
+ * Returns all public functions that return ModuleSet and take no parameters.
+ *
+ * This consolidates the duplicated discovery logic that was in both UltimateModuleSets and ultimateGenerator.
+ */
+fun discoverModuleSets(provider: Any): List {
+ val lookup = MethodHandles.lookup()
+ val clazz = provider.javaClass
+ val methodType = MethodType.methodType(ModuleSet::class.java)
+
+ val declaredMethods = clazz.declaredMethods
+ val result = ArrayList(declaredMethods.size)
+ for (method in declaredMethods) {
+ if (method.parameterCount == 0 &&
+ java.lang.reflect.Modifier.isPublic(method.modifiers) &&
+ method.returnType == ModuleSet::class.java) {
+ val moduleSet = lookup.findVirtual(clazz, method.name, methodType).invoke(provider) as ModuleSet
+ result.add(moduleSet)
+ }
+ }
+ return result
+}
diff --git a/platform/build-scripts/product-dsl/src/ModuleSetJsonExport.kt b/platform/build-scripts/product-dsl/src/ModuleSetJsonExport.kt
new file mode 100644
index 000000000000..8164193456f8
--- /dev/null
+++ b/platform/build-scripts/product-dsl/src/ModuleSetJsonExport.kt
@@ -0,0 +1,1917 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package org.jetbrains.intellij.build.productLayout
+
+import com.fasterxml.jackson.core.JsonEncoding
+import com.fasterxml.jackson.core.JsonFactory
+import com.fasterxml.jackson.core.JsonGenerator
+import com.intellij.openapi.util.JDOMUtil
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import java.nio.file.Files
+import java.nio.file.Path
+import java.time.Instant
+
+/**
+ * Metadata about a module set including its location and source file.
+ *
+ * @param moduleSet The module set instance
+ * @param location The location category ("community" or "ultimate")
+ * @param sourceFile The Kotlin source file path relative to project root where this module set is defined
+ */
+data class ModuleSetMetadata(
+ val moduleSet: ModuleSet,
+ val location: String,
+ val sourceFile: String
+)
+
+/**
+ * JSON filter for selective analysis output.
+ * Supports various filter types: products, moduleSets, composition, duplicates, or specific items.
+ */
+@Serializable
+data class JsonFilter(
+ val filter: String, // "products", "moduleSets", "composition", "duplicates", "product", "moduleSet"
+ val value: String? = null, // Product/module set name when filter is "product" or "moduleSet"
+ val includeDuplicates: Boolean = false // Include duplicate xi:include detection in output (for future unification)
+)
+
+/**
+ * Product specification for JSON output.
+ * Contains essential product metadata with source file paths for AI navigation,
+ * plus complete ProductModulesContentSpec for full DSL representation.
+ */
+data class ProductSpec(
+ val name: String,
+ val className: String?,
+ val sourceFile: String,
+ val pluginXmlPath: String?,
+ val contentSpec: ProductModulesContentSpec?,
+ val buildModules: List,
+ val totalModuleCount: Int = 0, // All modules including from module sets
+ val directModuleCount: Int = 0, // Just additionalModules count
+ val moduleSetCount: Int = 0, // Number of module sets included
+ val uniqueModuleCount: Int = 0 // Deduplicated module count
+)
+
+/**
+ * Module location information from .idea/modules.xml.
+ *
+ * @param location Module location: "community", "ultimate", or "unknown"
+ * @param imlPath Absolute path to the .iml file
+ */
+data class ModuleLocationInfo(
+ val location: String, // "community", "ultimate", or "unknown"
+ val imlPath: String?
+)
+
+/**
+ * Parses .idea/modules.xml to determine module locations (community vs ultimate).
+ * This information is used by validation functions to ensure architectural constraints.
+ *
+ * @param projectRoot Absolute path to the project root directory
+ * @return Map of module name to ModuleLocationInfo
+ */
+fun parseModulesXml(projectRoot: Path): Map {
+ val modulesXmlPath = projectRoot.resolve(".idea/modules.xml")
+
+ if (!Files.exists(modulesXmlPath)) {
+ return emptyMap()
+ }
+
+ val modules = mutableMapOf()
+
+ try {
+ val document = JDOMUtil.load(modulesXmlPath)
+
+ // Get all elements under
+ val projectModuleManager = document.getChildren("component")
+ .find { it.getAttributeValue("name") == "ProjectModuleManager" }
+
+ val modulesParent = projectModuleManager?.getChild("modules") ?: return emptyMap()
+
+ for (moduleElement in modulesParent.getChildren("module")) {
+ var filepath = moduleElement.getAttributeValue("filepath") ?: continue
+
+ // Replace $PROJECT_DIR$ with actual project root
+ filepath = filepath.replace("\$PROJECT_DIR\$", projectRoot.toString())
+
+ // Extract module name from .iml filename
+ val moduleName = Path.of(filepath).fileName.toString().removeSuffix(".iml")
+
+ // Determine location based on filepath
+ val location = when {
+ filepath.contains("/community/") -> "community"
+ filepath.contains("/ultimate/") -> "ultimate"
+ else -> "unknown"
+ }
+
+ modules[moduleName] = ModuleLocationInfo(location, filepath)
+ }
+ }
+ catch (e: Exception) {
+ // If parsing fails, return empty map (validation will report as unknown)
+ System.err.println("Warning: Failed to parse .idea/modules.xml: ${e.message}")
+ }
+
+ return modules
+}
+
+/**
+ * Derives source file path from ProductProperties class name.
+ * Maps package structure to file system path relative to project root.
+ */
+fun getProductPropertiesSourceFile(clazz: Class<*>, @Suppress("UNUSED_PARAMETER") projectRoot: Path): String {
+ val className = clazz.name
+
+ // Map package to directory structure
+ // org.jetbrains.intellij.build.IdeaUltimateProperties -> build/src/org/jetbrains/intellij/build/IdeaUltimateProperties.kt
+ // org.jetbrains.intellij.build.goland.GoLandProperties -> goland/intellij-go-build/src/org/jetbrains/intellij/build/goland/GoLandProperties.kt
+
+ val packagePath = className.replace('.', '/') + ".kt"
+
+ // Handle special cases based on package
+ return when {
+ className.startsWith("org.jetbrains.intellij.build.goland.") ->
+ "goland/intellij-go-build/src/$packagePath"
+ className.startsWith("org.jetbrains.intellij.build.clion.") ->
+ "CIDR/clion-build/src/$packagePath"
+ className.startsWith("com.jetbrains.rider.build.") ->
+ "rider/build/src/$packagePath"
+ className.startsWith("org.jetbrains.intellij.build.dataGrip.") ->
+ "dbe/build/src/$packagePath"
+ className.startsWith("org.jetbrains.intellij.build.") && !className.contains("community") ->
+ "build/src/$packagePath"
+ className.startsWith("org.jetbrains.intellij.build.") ->
+ "community/build/src/$packagePath"
+ else -> packagePath // Fallback to package path
+ }
+}
+
+// kotlinx.serialization Json instance for serializing data structures
+private val kotlinxJson = Json {
+ prettyPrint = false
+ encodeDefaults = true
+}
+
+/**
+ * Enriches products with calculated metrics.
+ * Calculates totalModuleCount, directModuleCount, moduleSetCount, and uniqueModuleCount for each product.
+ */
+private fun enrichProductsWithMetrics(
+ products: List,
+ moduleSets: List
+): List {
+ return products.map { product ->
+ val contentSpec = product.contentSpec
+ if (contentSpec == null) {
+ product // Return as-is if no contentSpec
+ } else {
+ // Calculate metrics
+ val allModules = mutableSetOf()
+
+ // Collect modules from module sets
+ for (msRef in contentSpec.moduleSets) {
+ val modulesFromSet = collectAllModuleNamesFromSet(moduleSets, msRef.moduleSet.name)
+ allModules.addAll(modulesFromSet)
+ }
+
+ // Add additional modules
+ for (module in contentSpec.additionalModules) {
+ allModules.add(module.name)
+ }
+
+ // Remove excluded modules
+ for (excludedModule in contentSpec.excludedModules) {
+ allModules.remove(excludedModule)
+ }
+
+ val totalModuleCount = allModules.size
+ val directModuleCount = contentSpec.additionalModules.size
+ val moduleSetCount = contentSpec.moduleSets.size
+ val uniqueModuleCount = allModules.size // Same as total after deduplication
+
+ product.copy(
+ totalModuleCount = totalModuleCount,
+ directModuleCount = directModuleCount,
+ moduleSetCount = moduleSetCount,
+ uniqueModuleCount = uniqueModuleCount
+ )
+ }
+ }
+}
+
+/**
+ * Streams comprehensive module set and product analysis data as JSON to stdout.
+ * Uses hybrid approach:
+ * - Jackson JsonGenerator for overall structure and analysis sections
+ * - kotlinx.serialization for automatic serialization of DSL data structures (ModuleSet, ProductModulesContentSpec)
+ *
+ * Generic function that accepts discovered module sets and products - similar to buildProductContentXml pattern.
+ * Caller is responsible for discovering module sets and products and providing them.
+ *
+ * Output includes:
+ * - Module sets with complete ModuleSet structure (modules, nested sets, aliases)
+ * - Products with full ProductModulesContentSpec (module sets with overrides, additional modules, exclusions)
+ * - Source file paths (relative to project root) for easy navigation
+ * - Duplicate analysis (modules in multiple sets)
+ * - Set overlap analysis (for unification opportunities)
+ *
+ * This data enables AI to:
+ * - See complete product DSL specifications with all modules and overrides
+ * - Detect duplicate modules across module sets
+ * - Find unification opportunities
+ * - Identify redundant includes or module specifications
+ * - Analyze module set overlap and relationships
+ * - Navigate to source files to make changes
+ *
+ * @param allModuleSets List of ModuleSetMetadata instances with module set, location, and source file path
+ * @param products List of ProductSpec instances
+ * @param projectRoot Project root path for resolving .idea/modules.xml
+ * @param filter Optional filter: null (full), or JsonFilter object with filter type and optional value
+ */
+fun streamModuleAnalysisJson(
+ allModuleSets: List,
+ products: List,
+ projectRoot: Path,
+ filter: JsonFilter? = null
+) {
+ // Validate product specifications using shared validation (always validate, even when filtering)
+ val moduleSets = allModuleSets.map { it.moduleSet }
+ val productSpecs = products.map { it.name to it.contentSpec }
+ validateNoRedundantModuleSets(moduleSets, productSpecs)
+
+ // Enrich products with calculated metrics
+ val enrichedProducts = enrichProductsWithMetrics(products, moduleSets)
+
+ val generator = JsonFactory()
+ .createGenerator(System.out, JsonEncoding.UTF8)
+ .configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false)
+ .configure(JsonGenerator.Feature.FLUSH_PASSED_TO_STREAM, false)
+ .useDefaultPrettyPrinter()
+
+ generator.use { gen ->
+ gen.writeStartObject()
+ gen.writeStringField("timestamp", Instant.now().toString())
+
+ // Apply filter
+ when {
+ filter == null -> {
+ // Full JSON (no filter)
+ writeAllSections(gen, allModuleSets, enrichedProducts, projectRoot)
+ }
+ filter.filter == "products" -> {
+ gen.writeArrayFieldStart("products")
+ for (product in enrichedProducts) {
+ writeProduct(gen, product)
+ }
+ gen.writeEndArray()
+ }
+ filter.filter == "moduleSets" -> {
+ gen.writeArrayFieldStart("moduleSets")
+ for ((moduleSet, location, sourceFilePath) in allModuleSets) {
+ writeModuleSet(gen, moduleSet, location, sourceFilePath, moduleSets)
+ }
+ gen.writeEndArray()
+ }
+ filter.filter == "composition" -> {
+ gen.writeObjectFieldStart("productCompositionAnalysis")
+ writeProductCompositionAnalysis(gen, enrichedProducts)
+ gen.writeEndObject()
+ }
+ filter.filter == "duplicates" -> {
+ gen.writeObjectFieldStart("duplicateAnalysis")
+ writeDuplicateAnalysis(gen, allModuleSets)
+ gen.writeEndObject()
+ }
+ filter.filter == "product" && filter.value != null -> {
+ val productName = filter.value
+ val product = enrichedProducts.firstOrNull { it.name == productName }
+ if (product != null) {
+ gen.writeFieldName("product")
+ gen.writeStartObject()
+ // Copy writeProduct logic inline but without outer object wrapper
+ gen.writeStringField("name", product.name)
+ gen.writeStringField("className", product.className)
+ gen.writeStringField("sourceFile", product.sourceFile)
+ if (product.pluginXmlPath != null) {
+ gen.writeStringField("pluginXmlPath", product.pluginXmlPath)
+ }
+ if (product.contentSpec != null) {
+ val contentSpecJson = kotlinxJson.encodeToString(product.contentSpec)
+ gen.writeFieldName("contentSpec")
+ gen.writeRawValue(contentSpecJson)
+ }
+ gen.writeArrayFieldStart("buildModules")
+ for (buildModule in product.buildModules) {
+ gen.writeString(buildModule)
+ }
+ gen.writeEndArray()
+ gen.writeEndObject()
+ } else {
+ gen.writeStringField("error", "Product '$productName' not found")
+ }
+ }
+ filter.filter == "moduleSet" && filter.value != null -> {
+ val moduleSetName = filter.value
+ val moduleSetEntry = allModuleSets.firstOrNull { it.moduleSet.name == moduleSetName }
+ if (moduleSetEntry != null) {
+ gen.writeFieldName("moduleSet")
+ gen.writeStartObject()
+ gen.writeStringField("name", moduleSetEntry.moduleSet.name)
+ gen.writeStringField("location", moduleSetEntry.location)
+ gen.writeStringField("sourceFile", moduleSetEntry.sourceFile)
+ val moduleSetJson = kotlinxJson.encodeToString(moduleSetEntry.moduleSet)
+ gen.writeFieldName("moduleSet")
+ gen.writeRawValue(moduleSetJson)
+ gen.writeEndObject()
+ } else {
+ gen.writeStringField("error", "Module set '$moduleSetName' not found")
+ }
+ }
+ else -> {
+ gen.writeStringField("error", "Unknown filter: ${filter.filter}")
+ }
+ }
+
+ gen.writeEndObject()
+ }
+
+ // Flush stdout to ensure all data is written
+ System.out.flush()
+}
+
+/**
+ * Writes all sections of the analysis JSON (used when no filter is specified).
+ */
+private fun writeAllSections(
+ gen: JsonGenerator,
+ allModuleSets: List,
+ products: List,
+ projectRoot: Path
+) {
+ // Write module sets
+ val moduleSets = allModuleSets.map { it.moduleSet }
+ gen.writeArrayFieldStart("moduleSets")
+ for ((moduleSet, location, sourceFilePath) in allModuleSets) {
+ writeModuleSet(gen, moduleSet, location, sourceFilePath, moduleSets)
+ }
+ gen.writeEndArray()
+
+ // Write products
+ gen.writeArrayFieldStart("products")
+ for (product in products) {
+ writeProduct(gen, product)
+ }
+ gen.writeEndArray()
+
+ // Write duplicate analysis
+ gen.writeObjectFieldStart("duplicateAnalysis")
+ writeDuplicateAnalysis(gen, allModuleSets)
+ gen.writeEndObject()
+
+ // Write product composition analysis
+ gen.writeObjectFieldStart("productCompositionAnalysis")
+ writeProductCompositionAnalysis(gen, products)
+ gen.writeEndObject()
+
+ // Write module distribution analysis
+ gen.writeObjectFieldStart("moduleDistribution")
+ writeModuleDistribution(gen, allModuleSets, products, projectRoot)
+ gen.writeEndObject()
+
+ // Write module set hierarchy
+ gen.writeObjectFieldStart("moduleSetHierarchy")
+ writeModuleSetHierarchy(gen, allModuleSets)
+ gen.writeEndObject()
+
+ // Write module usage index
+ gen.writeObjectFieldStart("moduleUsageIndex")
+ writeModuleUsageIndex(gen, allModuleSets, products)
+ gen.writeEndObject()
+
+ // Parse module locations for validation
+ val moduleLocations = parseModulesXml(projectRoot)
+
+ // Validate community products don't use ultimate modules
+ val communityViolations = validateCommunityProducts(products, allModuleSets, moduleLocations, projectRoot)
+ gen.writeObjectFieldStart("communityProductViolations")
+ writeCommunityProductViolations(gen, communityViolations)
+ gen.writeEndObject()
+
+ // Validate module sets are in correct locations
+ val locationViolations = validateModuleSetLocations(allModuleSets, moduleLocations, projectRoot)
+ gen.writeObjectFieldStart("moduleSetLocationViolations")
+ writeModuleSetLocationViolations(gen, locationViolations)
+ gen.writeEndObject()
+
+ // Analyze product similarity for refactoring recommendations
+ val similarityPairs = analyzeProductSimilarity(products, similarityThreshold = 0.7)
+ gen.writeObjectFieldStart("productSimilarity")
+ writeProductSimilarityAnalysis(gen, similarityPairs, 0.7)
+ gen.writeEndObject()
+
+ // Detect module set overlaps (with nested set filtering to avoid false positives)
+ val moduleSetOverlaps = detectModuleSetOverlap(allModuleSets, minOverlapPercent = 50)
+ gen.writeObjectFieldStart("moduleSetOverlap")
+ writeModuleSetOverlapAnalysis(gen, moduleSetOverlaps, 50)
+ gen.writeEndObject()
+
+ // Generate unification suggestions based on overlaps and similarity
+ val unificationSuggestions = suggestModuleSetUnification(
+ allModuleSets = allModuleSets,
+ products = products,
+ overlaps = moduleSetOverlaps,
+ similarityPairs = similarityPairs,
+ maxSuggestions = 10,
+ strategy = "all"
+ )
+ gen.writeObjectFieldStart("unificationSuggestions")
+ writeUnificationSuggestions(gen, unificationSuggestions)
+ gen.writeEndObject()
+}
+
+/**
+ * Writes a single module set to JSON.
+ * Uses kotlinx.serialization to serialize the ModuleSet structure directly,
+ * then embeds the raw JSON using writeRawValue().
+ */
+private fun writeModuleSet(
+ gen: JsonGenerator,
+ moduleSet: ModuleSet,
+ location: String,
+ sourceFilePath: String,
+ allModuleSets: List
+) {
+ gen.writeStartObject()
+
+ // Metadata fields
+ gen.writeStringField("name", moduleSet.name)
+ gen.writeStringField("location", location)
+ gen.writeStringField("sourceFile", sourceFilePath)
+
+ // Serialize ModuleSet using kotlinx.serialization and write raw JSON
+ val moduleSetJson = kotlinxJson.encodeToString(moduleSet)
+ gen.writeFieldName("moduleSet")
+ gen.writeRawValue(moduleSetJson)
+
+ // Add flattened list of all modules (including from nested sets)
+ gen.writeArrayFieldStart("allModulesFlattened")
+ val allModules = collectAllModuleNamesFromSet(allModuleSets, moduleSet.name)
+ for (moduleName in allModules.sorted()) {
+ gen.writeString(moduleName)
+ }
+ gen.writeEndArray()
+
+ gen.writeEndObject()
+}
+
+/**
+ * Writes a single product to JSON.
+ * Uses kotlinx.serialization to serialize ProductModulesContentSpec directly,
+ * providing complete DSL structure with all modules, overrides, and exclusions.
+ */
+private fun writeProduct(
+ gen: JsonGenerator,
+ product: ProductSpec
+) {
+ gen.writeStartObject()
+ gen.writeStringField("name", product.name)
+ gen.writeStringField("className", product.className)
+ gen.writeStringField("sourceFile", product.sourceFile)
+
+ if (product.pluginXmlPath != null) {
+ gen.writeStringField("pluginXmlPath", product.pluginXmlPath)
+ }
+
+ // Serialize ProductModulesContentSpec using kotlinx.serialization and write raw JSON
+ if (product.contentSpec != null) {
+ val contentSpecJson = kotlinxJson.encodeToString(product.contentSpec)
+ gen.writeFieldName("contentSpec")
+ gen.writeRawValue(contentSpecJson)
+ }
+
+ // Build modules
+ gen.writeArrayFieldStart("buildModules")
+ for (buildModule in product.buildModules) {
+ gen.writeString(buildModule)
+ }
+ gen.writeEndArray()
+
+ // Metrics
+ gen.writeNumberField("totalModuleCount", product.totalModuleCount)
+ gen.writeNumberField("directModuleCount", product.directModuleCount)
+ gen.writeNumberField("moduleSetCount", product.moduleSetCount)
+ gen.writeNumberField("uniqueModuleCount", product.uniqueModuleCount)
+
+ gen.writeEndObject()
+}
+
+/**
+ * Writes duplicate analysis section.
+ */
+private fun writeDuplicateAnalysis(
+ gen: JsonGenerator,
+ allModuleSets: List
+) {
+ // Find modules that appear in multiple module sets
+ val moduleToSets = mutableMapOf>()
+ for ((moduleSet, _, _) in allModuleSets) {
+ val allModules = collectAllModuleNames(moduleSet)
+ for (moduleName in allModules) {
+ moduleToSets.getOrPut(moduleName) { mutableListOf() }.add(moduleSet.name)
+ }
+ }
+
+ val duplicateModules = moduleToSets.filter { it.value.size > 1 }
+
+ gen.writeArrayFieldStart("modulesInMultipleSets")
+ for ((moduleName, setNames) in duplicateModules.entries.sortedBy { it.key }) {
+ gen.writeStartObject()
+ gen.writeStringField("moduleName", moduleName)
+
+ gen.writeArrayFieldStart("appearsInSets")
+ for (setName in setNames.sorted()) {
+ gen.writeString(setName)
+ }
+ gen.writeEndArray()
+
+ gen.writeEndObject()
+ }
+ gen.writeEndArray()
+
+ // Set overlap analysis
+ gen.writeArrayFieldStart("setOverlapAnalysis")
+ for (i in allModuleSets.indices) {
+ for (j in i + 1 until allModuleSets.size) {
+ val (set1, _, _) = allModuleSets[i]
+ val (set2, _, _) = allModuleSets[j]
+
+ val modules1 = collectAllModuleNames(set1)
+ val modules2 = collectAllModuleNames(set2)
+
+ val overlap = modules1.intersect(modules2)
+ if (overlap.size > 5) { // Only report significant overlaps
+ val uniqueToSet1 = modules1 - modules2
+ val uniqueToSet2 = modules2 - modules1
+
+ gen.writeStartObject()
+ gen.writeStringField("set1", set1.name)
+ gen.writeStringField("set2", set2.name)
+ gen.writeNumberField("overlapCount", overlap.size)
+ gen.writeNumberField("set1TotalModules", modules1.size)
+ gen.writeNumberField("set2TotalModules", modules2.size)
+
+ val overlapPercentage = (overlap.size.toDouble() / minOf(modules1.size, modules2.size)) * 100
+ gen.writeNumberField("overlapPercentage", overlapPercentage)
+
+ if (uniqueToSet1.isNotEmpty()) {
+ gen.writeArrayFieldStart("uniqueToSet1")
+ for (moduleName in uniqueToSet1.sorted().take(10)) { // Limit to first 10
+ gen.writeString(moduleName)
+ }
+ gen.writeEndArray()
+ }
+
+ if (uniqueToSet2.isNotEmpty()) {
+ gen.writeArrayFieldStart("uniqueToSet2")
+ for (moduleName in uniqueToSet2.sorted().take(10)) { // Limit to first 10
+ gen.writeString(moduleName)
+ }
+ gen.writeEndArray()
+ }
+
+ gen.writeEndObject()
+ }
+ }
+ }
+ gen.writeEndArray()
+}
+
+/**
+ * Analyzes product composition graphs and writes insights to JSON.
+ * Provides statistics and recommendations for each product based on its composition.
+ */
+private fun writeProductCompositionAnalysis(
+ gen: JsonGenerator,
+ products: List
+) {
+ gen.writeArrayFieldStart("products")
+
+ for (product in products) {
+ val contentSpec = product.contentSpec ?: continue
+ val compositionGraph = contentSpec.compositionGraph
+
+ if (compositionGraph.isEmpty()) continue
+
+ gen.writeStartObject()
+ gen.writeStringField("productName", product.name)
+
+ // Count composition types
+ val typeCounts = compositionGraph.groupBy { it.type }.mapValues { it.value.size }
+ gen.writeObjectFieldStart("compositionCounts")
+ for ((type, count) in typeCounts.entries.sortedBy { it.key.name }) {
+ gen.writeNumberField(type.name.lowercase(), count)
+ }
+ gen.writeEndObject()
+
+ // Total operations
+ gen.writeNumberField("totalCompositionOperations", compositionGraph.size)
+
+ // List module set references
+ val moduleSetRefs = compositionGraph.filter { it.type == CompositionType.MODULE_SET_REF }
+ if (moduleSetRefs.isNotEmpty()) {
+ gen.writeArrayFieldStart("moduleSetReferences")
+ for (ref in moduleSetRefs) {
+ gen.writeStartObject()
+ gen.writeStringField("name", ref.reference ?: "unknown")
+ gen.writeArrayFieldStart("path")
+ for (pathItem in ref.path) {
+ gen.writeString(pathItem)
+ }
+ gen.writeEndArray()
+ gen.writeEndObject()
+ }
+ gen.writeEndArray()
+ }
+
+ // List inline spec includes
+ val inlineSpecs = compositionGraph.filter { it.type == CompositionType.INLINE_SPEC }
+ if (inlineSpecs.isNotEmpty()) {
+ gen.writeArrayFieldStart("inlineSpecIncludes")
+ for (spec in inlineSpecs) {
+ gen.writeStartObject()
+ gen.writeStringField("reference", spec.reference ?: "unknown")
+ if (spec.sourceLocation != null) {
+ gen.writeStringField("sourceFile", spec.sourceLocation)
+ }
+ gen.writeEndObject()
+ }
+ gen.writeEndArray()
+ }
+
+ gen.writeEndObject()
+ }
+
+ gen.writeEndArray()
+}
+
+/**
+ * Writes module distribution analysis.
+ * For each module, lists which module sets and products use it, plus location information.
+ * Replaces TypeScript analyzeModuleDistribution() function.
+ *
+ * Output format: { "moduleName": { "inModuleSets": [...], "inProducts": [...], "location": "...", "imlPath": "..." } }
+ */
+private fun writeModuleDistribution(
+ gen: JsonGenerator,
+ allModuleSets: List,
+ products: List,
+ projectRoot: Path
+) {
+ // Parse module locations from .idea/modules.xml
+ val moduleLocations = parseModulesXml(projectRoot)
+
+ // Build module → {inModuleSets: [], inProducts: []} mapping
+ val moduleMap = mutableMapOf()
+
+ // Collect modules from module sets
+ for ((moduleSet, _, _) in allModuleSets) {
+ for (module in moduleSet.modules) {
+ val info = moduleMap.getOrPut(module.name) { ModuleDistributionInfo() }
+ info.inModuleSets.add(moduleSet.name)
+ }
+ }
+
+ // Collect modules from products
+ for (product in products) {
+ val contentSpec = product.contentSpec ?: continue
+
+ // Collect all modules used by this product (from module sets + additional modules)
+ val allModulesInProduct = mutableSetOf()
+
+ // Collect from module sets (recursively)
+ for (msRef in contentSpec.moduleSets) {
+ val modulesFromSet = collectAllModuleNamesFromSet(allModuleSets.map { it.moduleSet }, msRef.moduleSet.name)
+ allModulesInProduct.addAll(modulesFromSet)
+ }
+
+ // Add additional modules
+ for (module in contentSpec.additionalModules) {
+ allModulesInProduct.add(module.name)
+ }
+
+ // Add to module map
+ for (moduleName in allModulesInProduct) {
+ val info = moduleMap.getOrPut(moduleName) { ModuleDistributionInfo() }
+ if (product.name !in info.inProducts) {
+ info.inProducts.add(product.name)
+ }
+ }
+ }
+
+ // Set location information from .idea/modules.xml
+ for ((moduleName, info) in moduleMap) {
+ val locationInfo = moduleLocations[moduleName]
+ if (locationInfo != null) {
+ info.location = locationInfo.location
+ info.imlPath = locationInfo.imlPath
+ }
+ }
+
+ // Write JSON as object (not array) for direct access by module name
+ for ((moduleName, info) in moduleMap.entries.sortedBy { it.key }) {
+ gen.writeObjectFieldStart(moduleName)
+
+ gen.writeArrayFieldStart("inModuleSets")
+ for (setName in info.inModuleSets.sorted()) {
+ gen.writeString(setName)
+ }
+ gen.writeEndArray()
+
+ gen.writeArrayFieldStart("inProducts")
+ for (productName in info.inProducts.sorted()) {
+ gen.writeString(productName)
+ }
+ gen.writeEndArray()
+
+ gen.writeStringField("location", info.location)
+ if (info.imlPath != null) {
+ gen.writeStringField("imlPath", info.imlPath)
+ }
+
+ gen.writeEndObject()
+ }
+}
+
+/**
+ * Helper data class for module distribution analysis.
+ */
+private data class ModuleDistributionInfo(
+ val inModuleSets: MutableList = mutableListOf(),
+ val inProducts: MutableList = mutableListOf(),
+ var location: String = "unknown", // "community", "ultimate", or "unknown"
+ var imlPath: String? = null
+)
+
+/**
+ * Writes module set hierarchy analysis.
+ * For each module set, lists what it includes and what includes it.
+ * Replaces TypeScript buildModuleSetHierarchy() function.
+ *
+ * Output format: { "setName": { "includes": [...], "includedBy": [...], "moduleCount": N } }
+ */
+private fun writeModuleSetHierarchy(
+ gen: JsonGenerator,
+ allModuleSets: List
+) {
+ // Build hierarchy map: moduleSetName → {includes: [], includedBy: [], moduleCount: N}
+ val hierarchy = mutableMapOf()
+
+ // First pass: build includes and module counts
+ for ((moduleSet, _, _) in allModuleSets) {
+ val info = ModuleSetHierarchyInfo(
+ includes = moduleSet.nestedSets.map { it.name },
+ moduleCount = moduleSet.modules.size
+ )
+ hierarchy[moduleSet.name] = info
+ }
+
+ // Second pass: build reverse references (includedBy)
+ for ((moduleSet, _, _) in allModuleSets) {
+ for (nestedSet in moduleSet.nestedSets) {
+ hierarchy[nestedSet.name]?.includedBy?.add(moduleSet.name)
+ }
+ }
+
+ // Write JSON as flat object (no "moduleSets" wrapper) for direct access
+ for ((setName, info) in hierarchy.entries.sortedBy { it.key }) {
+ gen.writeObjectFieldStart(setName)
+
+ gen.writeArrayFieldStart("includes")
+ for (includedSet in info.includes.sorted()) {
+ gen.writeString(includedSet)
+ }
+ gen.writeEndArray()
+
+ gen.writeArrayFieldStart("includedBy")
+ for (parentSet in info.includedBy.sorted()) {
+ gen.writeString(parentSet)
+ }
+ gen.writeEndArray()
+
+ gen.writeNumberField("moduleCount", info.moduleCount)
+
+ gen.writeEndObject()
+ }
+}
+
+/**
+ * Helper data class for module set hierarchy analysis.
+ */
+private data class ModuleSetHierarchyInfo(
+ val includes: List,
+ val includedBy: MutableList = mutableListOf(),
+ val moduleCount: Int
+)
+
+/**
+ * Writes module usage index.
+ * For each module, provides complete information about where it's used and how to navigate to it.
+ * Replaces TypeScript findModuleUsages() function.
+ */
+private fun writeModuleUsageIndex(
+ gen: JsonGenerator,
+ allModuleSets: List,
+ products: List
+) {
+ // Build comprehensive usage index
+ val usageIndex = mutableMapOf()
+
+ // Collect from module sets
+ for ((moduleSet, location, sourceFile) in allModuleSets) {
+ for (module in moduleSet.modules) {
+ val info = usageIndex.getOrPut(module.name) { ModuleUsageInfo() }
+ info.moduleSets.add(ModuleSetReference(moduleSet.name, location, sourceFile))
+ }
+ }
+
+ // Collect from products
+ for (product in products) {
+ val contentSpec = product.contentSpec ?: continue
+
+ // Collect all modules used by this product
+ val allModulesInProduct = mutableSetOf()
+
+ // From module sets
+ for (msRef in contentSpec.moduleSets) {
+ val modulesFromSet = collectAllModuleNamesFromSet(allModuleSets.map { it.moduleSet }, msRef.moduleSet.name)
+ allModulesInProduct.addAll(modulesFromSet)
+ }
+
+ // From additional modules
+ for (module in contentSpec.additionalModules) {
+ allModulesInProduct.add(module.name)
+ }
+
+ // Add to index
+ for (moduleName in allModulesInProduct) {
+ val info = usageIndex.getOrPut(moduleName) { ModuleUsageInfo() }
+ info.products.add(ProductReference(product.name, product.sourceFile))
+ }
+ }
+
+ // Write JSON
+ gen.writeObjectFieldStart("modules")
+ for ((moduleName, info) in usageIndex.entries.sortedBy { it.key }) {
+ gen.writeObjectFieldStart(moduleName)
+
+ // Module sets that include this module
+ gen.writeArrayFieldStart("moduleSets")
+ for (msRef in info.moduleSets.sortedBy { it.name }) {
+ gen.writeStartObject()
+ gen.writeStringField("name", msRef.name)
+ gen.writeStringField("location", msRef.location)
+ gen.writeStringField("sourceFile", msRef.sourceFile)
+ gen.writeEndObject()
+ }
+ gen.writeEndArray()
+
+ // Products that use this module
+ gen.writeArrayFieldStart("products")
+ for (prodRef in info.products.sortedBy { it.name }) {
+ gen.writeStartObject()
+ gen.writeStringField("name", prodRef.name)
+ gen.writeStringField("sourceFile", prodRef.sourceFile)
+ gen.writeEndObject()
+ }
+ gen.writeEndArray()
+
+ gen.writeEndObject()
+ }
+ gen.writeEndObject()
+}
+
+/**
+ * Helper data classes for module usage index.
+ */
+private data class ModuleUsageInfo(
+ val moduleSets: MutableList = mutableListOf(),
+ val products: MutableList = mutableListOf()
+)
+
+private data class ModuleSetReference(
+ val name: String,
+ val location: String,
+ val sourceFile: String
+)
+
+private data class ProductReference(
+ val name: String,
+ val sourceFile: String
+)
+
+/**
+ * Recursively collects all module names from a module set and its nested sets.
+ * Helper function for module distribution and usage analysis.
+ */
+private fun collectAllModuleNamesFromSet(
+ moduleSets: List,
+ setName: String,
+ visited: MutableSet = mutableSetOf()
+): Set {
+ if (setName in visited) return emptySet()
+ visited.add(setName)
+
+ val moduleSet = moduleSets.firstOrNull { it.name == setName } ?: return emptySet()
+
+ val allModules = moduleSet.modules.map { it.name }.toMutableSet()
+
+ // Recursively collect from nested sets
+ for (nestedSet in moduleSet.nestedSets) {
+ allModules.addAll(collectAllModuleNamesFromSet(moduleSets, nestedSet.name, visited))
+ }
+
+ return allModules
+}
+
+/**
+ * Violation when a community product uses ultimate modules.
+ */
+private data class CommunityProductViolation(
+ val product: String,
+ val productFile: String,
+ val moduleSet: String,
+ val moduleSetFile: String,
+ val ultimateModules: List,
+ val communityModulesCount: Int,
+ val unknownModulesCount: Int,
+ val totalModulesCount: Int
+)
+
+/**
+ * Violation when a module set is in the wrong location (community vs ultimate).
+ */
+private data class ModuleSetLocationViolation(
+ val moduleSet: String,
+ val file: String,
+ val issue: String, // "community_contains_ultimate" or "ultimate_contains_only_community"
+ val ultimateModules: List? = null,
+ val communityModules: List? = null,
+ val communityModulesCount: Int? = null,
+ val ultimateModulesCount: Int? = null,
+ val unknownModulesCount: Int,
+ val suggestion: String
+)
+
+/**
+ * Similarity between two products based on module set overlap.
+ * Used for identifying merge candidates and refactoring opportunities.
+ */
+private data class ProductSimilarityPair(
+ val product1: String,
+ val product2: String,
+ val similarity: Double,
+ val moduleSetSimilarity: Double,
+ val sharedModuleSets: List,
+ val uniqueToProduct1: List,
+ val uniqueToProduct2: List
+)
+
+/**
+ * Overlap between two module sets.
+ * Correctly identifies intentional nested set inclusions vs actual duplications.
+ * Intentional nesting (e.g., libraries includes libraries.core) is filtered out.
+ */
+private data class ModuleSetOverlap(
+ val moduleSet1: String,
+ val moduleSet2: String,
+ val location1: String,
+ val location2: String,
+ val relationship: String, // "overlap", "subset", "superset"
+ val overlapPercent: Int,
+ val sharedModules: Int,
+ val totalModules1: Int,
+ val totalModules2: Int,
+ val recommendation: String
+)
+
+/**
+ * Suggestion for module set unification (merge, inline, factor, split).
+ * Generated by analyzing overlap, product similarity, and module set usage patterns.
+ */
+private data class UnificationSuggestion(
+ val priority: String, // "high", "medium", "low"
+ val strategy: String, // "merge", "inline", "factor", "split"
+ val type: String?, // For merge: "subset", "superset", "high-overlap"
+ val moduleSet: String?, // For inline/split: single module set
+ val moduleSet1: String?, // For merge: first module set
+ val moduleSet2: String?, // For merge: second module set
+ val products: List?, // For factor: products with shared sets
+ val sharedModuleSets: List?, // For factor: shared module sets
+ val reason: String,
+ val impact: Map
+)
+
+/**
+ * Impact analysis result for merging, moving, or inlining module sets.
+ * Used to assess safety and predict consequences before refactoring.
+ */
+private data class MergeImpactResult(
+ val operation: String, // "merge", "move", "inline"
+ val sourceSet: String,
+ val targetSet: String?,
+ val productsUsingSource: List,
+ val productsUsingTarget: List,
+ val productsThatWouldChange: List,
+ val sizeImpact: Map,
+ val violations: List