Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4

[*.{kt,kts}]
ktlint_code_style = intellij_idea
max_line_length = off
ij_kotlin_packages_to_use_import_on_demand = io.ktor.**

[**/runtime/path/example/**]
ktlint = disabled

[*.{yml,yaml,json}]
indent_size = 2

[*.md]
trim_trailing_whitespace = false
13 changes: 8 additions & 5 deletions .github/workflows/build-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,17 @@ jobs:
java-version: '21'
distribution: 'temurin'

# Step 4: Run Gradle build
# Step 4: Quality gate — ktlint formatting + detekt static analysis.
# `--continue` so a single run collects EVERY finding across all modules and both tools
# (Gradle otherwise aborts on the first failing task, hiding later modules/detekt). The build
# still fails at the end if anything failed.
- name: Run quality gate (ktlint + detekt)
run: ./gradlew lintKotlin detekt --continue

# Step 5: Run Gradle build (compiles all modules and runs unit + integration tests;
- name: Run Gradle Build
run: ./gradlew build --warning-mode all

# Step 5: Run Detekt static analysis
- name: Run Detekt
run: ./gradlew detekt

# Step 6: Enforce per-class test coverage (≥ 75%) for modules with in-process tests
- name: Check Coverage
run: ./gradlew :bpmn-to-code-core:jacocoTestCoverageVerification :bpmn-to-code-web:jacocoTestCoverageVerification :bpmn-to-code-testing:jacocoTestCoverageVerification :bpmn-to-code-runtime:jacocoTestCoverageVerification
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ lefthook install
./gradlew :bpmn-to-code-maven:test
```

### Code Quality (ktlint + detekt)
Kotlin quality is enforced by ktlint (formatting/imports) and detekt (semantic/structural). Both are
wired into `check`/`build` and gate CI + the pre-push hook.
```bash
./gradlew lintKotlin # ktlint check
./gradlew formatKotlin # ktlint auto-fix
./gradlew detekt # detekt
```
No baseline and no silent suppressions — fix findings or add a scoped exception in the relevant
config. ktlint config lives in `.editorconfig`, detekt config in `config/detekt/detekt.yml`.

### Plugin Development
The plugins generate code from BPMN files. Key configuration parameters:
- `filePattern`: BPMN file location pattern
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import org.junit.jupiter.params.provider.ValueSource
class ExternalModuleImportTest {

private val forbiddenImportPrefixes = listOf(
"io.miragon.bpmn.application.", // services and ports
"io.miragon.bpmn.application.", // services and ports
"io.miragon.bpmn.adapter.outbound.", // out-adapters
)

Expand All @@ -27,7 +27,7 @@ class ExternalModuleImportTest {
"bpmn-to-code-gradle",
"bpmn-to-code-maven",
"bpmn-to-code-web",
]
],
)
fun `plugin module only imports domain objects or inbound adapters from core`(modulePath: String) {
Konsist
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ class CreateProcessApiFilesystemPlugin(
outputLanguage = outputLanguage,
engine = engine,
validationConfig = validationConfig,
)
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,20 @@ class CreateProcessApiInMemoryPlugin(
outputLanguage: OutputLanguage,
engine: ProcessEngine,
validationConfig: ValidationConfig = ValidationConfig(),
): List<GeneratedApiFile> {
return useCase.generateProcessApi(
GenerateProcessApiInMemoryUseCase.Command(
packagePath = packagePath,
outputLanguage = outputLanguage,
engine = engine,
validationConfig = validationConfig,
bpmnContents = bpmnContents.map {
GenerateProcessApiInMemoryUseCase.BpmnInput(
bpmnXml = it.bpmnXml,
processName = it.processName
)
},
)
)
}
): List<GeneratedApiFile> = useCase.generateProcessApi(
GenerateProcessApiInMemoryUseCase.Command(
packagePath = packagePath,
outputLanguage = outputLanguage,
engine = engine,
validationConfig = validationConfig,
bpmnContents = bpmnContents.map {
GenerateProcessApiInMemoryUseCase.BpmnInput(
bpmnXml = it.bpmnXml,
processName = it.processName,
)
},
),
)

data class BpmnInput(
val bpmnXml: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ class CreateProcessJsonFilesystemPlugin(
outputFolderPath = outputFolderPath,
engine = engine,
validationConfig = validationConfig,
)
),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,18 @@ class CreateProcessJsonInMemoryPlugin(
bpmnContents: List<BpmnInput>,
engine: ProcessEngine,
validationConfig: ValidationConfig = ValidationConfig(),
): List<GeneratedJsonFile> {
return useCase.generateProcessJson(
GenerateProcessJsonInMemoryUseCase.Command(
engine = engine,
validationConfig = validationConfig,
bpmnContents = bpmnContents.map {
GenerateProcessJsonInMemoryUseCase.BpmnInput(
bpmnXml = it.bpmnXml,
processName = it.processName,
)
},
)
)
}
): List<GeneratedJsonFile> = useCase.generateProcessJson(
GenerateProcessJsonInMemoryUseCase.Command(
engine = engine,
validationConfig = validationConfig,
bpmnContents = bpmnContents.map {
GenerateProcessJsonInMemoryUseCase.BpmnInput(
bpmnXml = it.bpmnXml,
processName = it.processName,
)
},
),
)

data class BpmnInput(
val bpmnXml: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ class ValidateBpmnFilesystemPlugin(
filePattern = filePattern,
engine = engine,
validationConfig = validationConfig,
)
),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@ internal object ApiObjectSelection {
/**
* Whether [type] has anything to contribute for [modelApi].
*/
fun includes(type: ApiObjectType, modelApi: BpmnModelApi): Boolean {
return type.hasContentIn(modelApi)
}
fun includes(type: ApiObjectType, modelApi: BpmnModelApi): Boolean = type.hasContentIn(modelApi)

private fun ApiObjectType.hasContentIn(modelApi: BpmnModelApi): Boolean {
val model = modelApi.model
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ internal class CodeGenerationAdapter(
companion object {
val processApiBuilders = mapOf(
OutputLanguage.KOTLIN to KotlinProcessApiBuilder(),
OutputLanguage.JAVA to JavaProcessApiBuilder()
OutputLanguage.JAVA to JavaProcessApiBuilder(),
)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,4 @@ import io.miragon.bpmn.domain.shared.VariableMapping
* of them may legitimately share a name. The generated API has no such id — a name yields exactly one
* constant — so the collapsing happens here, at the point where names become identifiers.
*/
internal fun <T : VariableMapping<*>> List<T>.asApiConstants(): List<T> {
return filter { it.getRawName().isNotEmpty() }.distinctBy { it.getRawName() }
}
internal fun <T : VariableMapping<*>> List<T>.asApiConstants(): List<T> = filter { it.getRawName().isNotEmpty() }.distinctBy { it.getRawName() }
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ internal class JavaNavigationWriter {
classBuilder.superclass(ClassName.get(RUNTIME_PACKAGE, "AbstractFlowNode"))
classBuilder.addMethod(
MethodSpec.constructorBuilder().addModifiers(PUBLIC)
.addStatement("super(new \$T(\$S), \$S)", elementIdClass, node.id, node.elementType).build()
.addStatement("super(new \$T(\$S), \$S)", elementIdClass, node.id, node.elementType).build(),
)
if (node.successors.isNotEmpty()) {
classBuilder.addSuperinterface(navigableType(node))
Expand All @@ -72,7 +72,7 @@ internal class JavaNavigationWriter {
private fun addInnerScope(classBuilder: TypeSpec.Builder, node: NavigationNode, inner: NavigationGraph) {
// Qualify with the node so a bare `Inner`/`Next` doesn't bind to an enclosing scope's type.
classBuilder.addSuperinterface(
ParameterizedTypeName.get(ClassName.get(RUNTIME_PACKAGE, "HasInnerScope"), ClassName.get("", node.objectName, "Inner"))
ParameterizedTypeName.get(ClassName.get(RUNTIME_PACKAGE, "HasInnerScope"), ClassName.get("", node.objectName, "Inner")),
)
classBuilder.addMethod(innerMethod(node))
classBuilder.addType(buildInnerScope(node, inner.nodes.filter { it.isStart }))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package io.miragon.bpmn.adapter.outbound.codegen.builder

import io.miragon.bpmn.adapter.outbound.codegen.ApiObjectSelection
import io.miragon.bpmn.adapter.outbound.codegen.ApiObjectType
import com.palantir.javapoet.ClassName
import com.palantir.javapoet.CodeBlock
import com.palantir.javapoet.FieldSpec
import com.palantir.javapoet.JavaFile
import com.palantir.javapoet.TypeSpec
import io.miragon.bpmn.adapter.outbound.codegen.ApiObjectSelection
import io.miragon.bpmn.adapter.outbound.codegen.ApiObjectType
import io.miragon.bpmn.adapter.outbound.codegen.CodeGenerationAdapter
import io.miragon.bpmn.adapter.outbound.codegen.writer.ObjectWriter
import io.miragon.bpmn.adapter.outbound.codegen.navigation.NavigationGraphFactory
import io.miragon.bpmn.adapter.outbound.codegen.writer.ObjectWriter
import io.miragon.bpmn.domain.BpmnModelApi
import io.miragon.bpmn.domain.GeneratedApiFile
import io.miragon.bpmn.domain.ProcessModel.Variant
Expand Down Expand Up @@ -102,7 +102,7 @@ internal class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiB
.addJavadoc(
"BPMN element ids as declared in the source model.\n" +
"Typically used in process-level tests or when searching for tasks.\n" +
"Worker runtime code rarely needs these.\n"
"Worker runtime code rarely needs these.\n",
)
modelApi.model.allFlowNodes.sortedBy { it.getRawName() }.forEach { flowNode ->
elementsBuilder.addField(createTypedAttribute(flowNode, elementIdClass))
Expand Down Expand Up @@ -156,7 +156,7 @@ internal class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiB
.addJavadoc(
"Sequence flows between BPMN elements.\n" +
"Mainly useful for process-model tooling, tests, and AI-agent consumers reasoning about the process shape.\n" +
"Worker code typically does not need these.\n"
"Worker code typically does not need these.\n",
)
sequenceFlows.sortedBy { it.getRawName() }.forEach { flow ->
val initCode = buildFlowInitializer(bpmnFlowClass, flow.id ?: "", flow.flowName, flow.sourceRef, flow.targetRef, flow.conditionExpression, flow.isDefault)
Expand Down Expand Up @@ -189,7 +189,7 @@ internal class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiB
"Typed navigation over the process flow. Each element is a node exposing its {@code id}, " +
"{@code elementType} and display {@code name}, plus the elements reachable from it as methods — " +
"so a full path is verified by the compiler and offered by autocomplete. A subprocess's interior " +
"is its nested {@code Inner} scope.\n"
"is its nested {@code Inner} scope.\n",
)
JavaNavigationWriter().write(relationsBuilder, NavigationGraphFactory.build(graph), staticAccessors = true)
return relationsBuilder.build()
Expand All @@ -201,7 +201,7 @@ internal class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiB
val callActivitiesBuilder = TypeSpec.classBuilder("CallActivities").addModifiers(PUBLIC, STATIC, FINAL)
.addJavadoc(
"Call activities grouped by element. Each nested class exposes the called {@code PROCESS_ID} plus " +
"the variable mappings passed into ({@code Inputs}) and returned from ({@code Outputs}) the called process.\n"
"the variable mappings passed into ({@code Inputs}) and returned from ({@code Outputs}) the called process.\n",
)
modelApi.model.callActivities
.sortedBy { it.getRawName() }
Expand All @@ -215,7 +215,7 @@ internal class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiB
classBuilder.addField(
FieldSpec.builder(processIdClass, "PROCESS_ID").addModifiers(PUBLIC, STATIC, FINAL)
.initializer("new \$T(\$S)", processIdClass, callActivity.getValue())
.build()
.build(),
)
buildMappingsClass("Inputs", callActivity.inputMappings)?.let { classBuilder.addType(it) }
buildMappingsClass("Outputs", callActivity.outputMappings)?.let { classBuilder.addType(it) }
Expand Down Expand Up @@ -269,7 +269,7 @@ internal class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiB
val tasksBuilder = TypeSpec.classBuilder("ServiceTasks").addModifiers(PUBLIC, STATIC, FINAL)
.addJavadoc(
"Job worker task types used in {@code @JobWorker(type = ServiceTasks.X)} annotations.\n" +
"Kept as {@code public static final String} because annotation arguments must be compile-time constants.\n"
"Kept as {@code public static final String} because annotation arguments must be compile-time constants.\n",
)
modelApi.model.serviceTasks.asApiConstants()
.forEach { task -> tasksBuilder.addField(createAttribute(task)) }
Expand Down Expand Up @@ -297,7 +297,7 @@ internal class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiB
.addJavadoc(
"Process variables grouped by the BPMN element that declares them.\n" +
"Direction is encoded in each variable's wrapper type: {@code VariableName.Input}, {@code VariableName.Output}, or {@code VariableName.InOut} when the variable is both read and written by the same element.\n" +
"Consumer APIs that take a specific subtype (for example, a method accepting {@code VariableName.Output}) get compile-time direction enforcement.\n"
"Consumer APIs that take a specific subtype (for example, a method accepting {@code VariableName.Output}) get compile-time direction enforcement.\n",
)
val nodesWithVariables = modelApi.model.allFlowNodes
.filter { it.variables.isNotEmpty() }
Expand Down Expand Up @@ -384,17 +384,13 @@ internal class JavaProcessApiBuilder : CodeGenerationAdapter.AbstractProcessApiB
}
}

private fun createAttribute(variable: VariableMapping<*>): FieldSpec {
return FieldSpec.builder(String::class.java, variable.getName())
.addModifiers(PUBLIC, STATIC, FINAL)
.initializer("\$S", variable.getValue())
.build()
}
private fun createAttribute(variable: VariableMapping<*>): FieldSpec = FieldSpec.builder(String::class.java, variable.getName())
.addModifiers(PUBLIC, STATIC, FINAL)
.initializer("\$S", variable.getValue())
.build()

private fun createTypedAttribute(variable: VariableMapping<String>, wrapperClass: ClassName): FieldSpec {
return FieldSpec.builder(wrapperClass, variable.getName())
.addModifiers(PUBLIC, STATIC, FINAL)
.initializer("new \$T(\$S)", wrapperClass, variable.getValue())
.build()
}
private fun createTypedAttribute(variable: VariableMapping<String>, wrapperClass: ClassName): FieldSpec = FieldSpec.builder(wrapperClass, variable.getName())
.addModifiers(PUBLIC, STATIC, FINAL)
.initializer("new \$T(\$S)", wrapperClass, variable.getValue())
.build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ internal class KotlinNavigationWriter {
private fun addInnerScope(nodeBuilder: TypeSpec.Builder, node: NavigationNode, inner: NavigationGraph) {
// Qualify with the node so a bare `Inner`/`Next` doesn't bind to an enclosing scope's type.
nodeBuilder.addSuperinterface(
ClassName(RUNTIME_PACKAGE, "HasInnerScope").parameterizedBy(ClassName("", node.objectName, "Inner"))
ClassName(RUNTIME_PACKAGE, "HasInnerScope").parameterizedBy(ClassName("", node.objectName, "Inner")),
)
nodeBuilder.addFunction(innerFunction(node))
nodeBuilder.addType(buildInnerScope(node, inner.nodes.filter { it.isStart }))
Expand Down Expand Up @@ -122,11 +122,9 @@ internal class KotlinNavigationWriter {
return PropertySpec.builder("calledProcess", processIdClass).initializer("ProcessId(%S)", calledProcessId).build()
}

private fun nodeAccessor(propertyName: String, objectName: String): PropertySpec {
return PropertySpec.builder(propertyName, ClassName("", objectName))
.getter(FunSpec.getterBuilder().addStatement("return %N", objectName).build())
.build()
}
private fun nodeAccessor(propertyName: String, objectName: String): PropertySpec = PropertySpec.builder(propertyName, ClassName("", objectName))
.getter(FunSpec.getterBuilder().addStatement("return %N", objectName).build())
.build()

private companion object {
private const val RUNTIME_PACKAGE = "io.miragon.bpmn.runtime"
Expand Down
Loading