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: 14 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,23 @@ BpmnValidator
.assertNoViolations()
```

11 built-in rules cover missing implementations, undefined timers, empty processes, naming violations, and variable collisions. Add custom rules by implementing `SingleModelValidationRule` (per-process) or `CrossModelValidationRule` (across all loaded models).
12 built-in rules cover missing implementations, undefined timers, empty processes, naming violations, leftover root elements, and variable collisions. Add custom rules by implementing `SingleModelValidationRule` (per-process) or `CrossModelValidationRule` (across all loaded models).

### Surface — Process Structure in Code _(beta)_

Generates a structured JSON alongside the API. Your process is readable by AI agents, code reviewers, and CI — without opening Camunda Modeler.

```json
{
"processId": "newsletterSubscription",
"flowNodes": [
{ "id": "StartEvent_SubmitRegistrationForm", "displayName": "Submit newsletter form", "elementType": "START_EVENT" },
{ "id": "Activity_SendConfirmationMail", "displayName": "Send confirmation mail", "elementType": "SERVICE_TASK" }
]
"$schema": "https://miragon.github.io/bpmn-to-code/schema/process-model/2.0.json",
"formatVersion": "2.0",
"process": {
"id": "newsletterSubscription",
"flowNodes": [
{ "id": "StartEvent_SubmitRegistrationForm", "type": "startEvent", "name": "Submit newsletter form" },
{ "id": "Activity_SendConfirmationMail", "type": "serviceTask", "name": "Send confirmation mail" }
]
}
}
```

Expand All @@ -64,6 +68,7 @@ BPMN files are XML — technically readable, but full of visual layout data, nam
- **Smaller** — no diagram coordinates, waypoints, or SVG-style metadata
- **Focused** — only the elements and relationships that matter for logic and implementation
- **Structured** — flow nodes, sequence flows, messages, and errors in predictable, typed fields
- **Standard** — element names, containment and references follow OMG BPMN 2.0, validated against a published JSON Schema

The result is a compact, deterministic representation that AI agents can reason about accurately — with no hallucinated element IDs, because the JSON is derived directly from the BPMN model by rule.

Expand All @@ -82,7 +87,7 @@ Works with Claude Code out of the box.

```kotlin
plugins {
id("io.miragon.bpmn-to-code-gradle") version "3.0.0"
id("io.miragon.bpmn-to-code-gradle") version "6.0.0"
}

tasks.named("generateBpmnModelApi", GenerateBpmnModelsTask::class) {
Expand All @@ -101,7 +106,7 @@ tasks.named("generateBpmnModelApi", GenerateBpmnModelsTask::class) {
<plugin>
<groupId>io.miragon</groupId>
<artifactId>bpmn-to-code-maven</artifactId>
<version>3.0.0</version>
<version>6.0.0</version>
<executions>
<execution>
<goals><goal>generate-bpmn-api</goal></goals>
Expand All @@ -122,7 +127,7 @@ tasks.named("generateBpmnModelApi", GenerateBpmnModelsTask::class) {

```kotlin
dependencies {
testImplementation("io.miragon:bpmn-to-code-testing:3.0.0")
testImplementation("io.miragon:bpmn-to-code-testing:6.0.0")
}
```

Expand Down
10 changes: 10 additions & 0 deletions bpmn-to-code-architecture-tests/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,14 @@ dependencies {

tasks.named<Test>("test") {
useJUnitPlatform()

// Konsist reads the other modules' sources from disk, which Gradle cannot see. Without declaring
// them the task stays UP-TO-DATE after any change outside this module, so a violation only surfaces
// on a clean CI checkout.
inputs.files(
fileTree(rootDir) {
include("bpmn-to-code-*/src/**/*.kt")
exclude("bpmn-to-code-architecture-tests/**")
},
).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("projectSources")
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,36 @@ class CodingGuidelinesTest {

@Test
fun `each source file declares at most one top-level type`() {
Konsist
.scopeFromProject()
.files
productionFiles()
.assertTrue(testName = "files should declare at most one top-level type to follow SRP") { file ->
file.classesAndInterfacesAndObjects(includeNested = false, includeLocal = false).size <= 1
}
}

/**
* A file that declares a type declares *only* that type: helpers belong inside it (or in their own
* file), not beside it. Without this, a class file slowly accumulates free functions that no reader
* expects to find there — which is how `ProcessModel.kt` grew a second, unrelated half.
*
* Files holding only top-level functions — a named collection of helpers with no type of its own —
* stay allowed.
*/
@Test
fun `a file declaring a type declares nothing else at top level`() {
productionFiles()
.filter { it.classesAndInterfacesAndObjects(includeNested = false, includeLocal = false).isNotEmpty() }
.assertTrue(testName = "a type's file should not also declare top-level functions or properties") { file ->
val functions = file.functions(includeNested = false, includeLocal = false)
val properties = file.properties(includeNested = false)
functions.isEmpty() && properties.isEmpty()
}
}

/**
* Project sources only — never the gitignored `bin/` output an IDE may leave behind.
*/
private fun productionFiles() = Konsist
.scopeFromProject()
.files
.filter { it.path.contains("/src/") }
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class ExternalModuleImportTest {
Konsist
.scopeFromProject()
.files
.filter { file -> file.path.contains(modulePath) }
.filter { file -> file.path.contains("/src/") && file.path.contains(modulePath) }
.assertTrue { file ->
file.imports.none { import ->
forbiddenImportPrefixes.any { import.name.startsWith(it) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,9 @@ class HexagonalArchitectureTest {

private companion object {

/** Restricts a whole-project scan to `bpmn-to-code-core`'s production sources. */
/**
* Restricts a whole-project scan to `bpmn-to-code-core`'s production sources.
*/
const val CORE_MAIN_PATH = "/bpmn-to-code-core/src/main/"

/**
Expand Down
10 changes: 8 additions & 2 deletions bpmn-to-code-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,30 @@ dependencies {
api(libs.kotlinLogging)
testImplementation(libs.bundles.testing)
testImplementation(kotlin("compiler-embeddable"))
testImplementation(libs.jsonSchemaValidator)
testRuntimeOnly(libs.junitPlatformLauncher)
}

sourceSets {
test {
resources.srcDir(rootProject.file("shared"))
// The published JSON schema, so ProcessJsonSchemaTest validates against it offline
resources.srcDir(rootProject.file("docs/public/schema"))
}
}

tasks.named<Test>("test") {
useJUnitPlatform()
// Lets `./gradlew test -Dgolden.update=true` rewrite the end-to-end JSON snapshots.
systemProperty("golden.update", System.getProperty("golden.update") ?: "false")
}

private val coverageExclusions = listOf(
"**/domain/shared/**",
"**/domain/validation/model/**",
"**/adapter/outbound/engine/constants/**",
"**/adapter/outbound/engine/extractor/*ImplementationKind*",
// Constant holders: their `const val`s are inlined at the call site, so the object itself
// never executes. Matched by name rather than by package so a move cannot silently re-include them.
"**/adapter/outbound/engine/**/*Constants*",
"**/adapter/outbound/json/model/**",
"**/application/port/**",
"**/*\$DefaultImpls*",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.miragon.bpmn.adapter.inbound

import io.miragon.bpmn.application.port.inbound.ExtractProcessModelsUseCase
import io.miragon.bpmn.application.service.ExtractProcessModelsService
import io.miragon.bpmn.domain.BpmnResource
import io.miragon.bpmn.domain.ProcessModel
import io.miragon.bpmn.domain.shared.ProcessEngine

class ExtractProcessModelsPlugin(
private val useCase: ExtractProcessModelsUseCase = ExtractProcessModelsService(),
) {

fun execute(resources: List<BpmnResource>, engine: ProcessEngine): List<ProcessModel> = useCase.extractProcessModels(
ExtractProcessModelsUseCase.Command(resources = resources, engine = engine),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package io.miragon.bpmn.adapter.inbound
import io.miragon.bpmn.application.port.inbound.ValidateBpmnFromFilesystemUseCase
import io.miragon.bpmn.application.service.ValidateBpmnService
import io.miragon.bpmn.domain.shared.ProcessEngine
import io.miragon.bpmn.domain.validation.model.ValidationConfig
import io.miragon.bpmn.domain.validation.ValidationResult
import io.miragon.bpmn.domain.validation.model.ValidationConfig

class ValidateBpmnFilesystemPlugin(
private val useCase: ValidateBpmnFromFilesystemUseCase = ValidateBpmnService(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.miragon.bpmn.adapter.outbound.codegen

import io.miragon.bpmn.domain.BpmnModelApi

/**
* Decides which sections a generated Process API contains.
*
* Today the only question is whether a section would have anything to say about the model — a process
* without messages gets no `Messages` object rather than an empty one. That is a mapping from the
* codegen vocabulary onto the domain, so it lives here, once, rather than in each language's builder or
* on [ApiObjectType] itself.
*
* Letting the caller choose the sections is a second, independent question. It is not implemented, but
* this is where it goes: [selectFrom] gains the requested set and nothing else has to move.
*/
internal object ApiObjectSelection {

/**
* Whether [type] has anything to contribute for [modelApi].
*/
fun includes(type: ApiObjectType, modelApi: BpmnModelApi): Boolean {
return type.hasContentIn(modelApi)
}

private fun ApiObjectType.hasContentIn(modelApi: BpmnModelApi): Boolean {
val model = modelApi.model
return when (this) {
ApiObjectType.PROCESS_ID, ApiObjectType.PROCESS_ENGINE, ApiObjectType.ELEMENTS -> true
ApiObjectType.FLOWS, ApiObjectType.RELATIONS -> !model.isMerged && model.graph.allSequenceFlows.isNotEmpty()
ApiObjectType.VARIANTS -> model.isMerged
ApiObjectType.CALL_ACTIVITIES -> model.callActivities.isNotEmpty()
ApiObjectType.MESSAGES -> model.definitions.messages.isNotEmpty()
ApiObjectType.SERVICE_TASKS -> model.serviceTasks.any { it.getRawName().isNotEmpty() }
ApiObjectType.TIMERS -> model.timers.isNotEmpty()
ApiObjectType.ERRORS -> model.definitions.errors.isNotEmpty()
ApiObjectType.ESCALATIONS -> model.definitions.escalations.isNotEmpty()
ApiObjectType.COMPENSATIONS -> model.compensations.isNotEmpty()
ApiObjectType.SIGNALS -> model.definitions.signals.isNotEmpty()
ApiObjectType.VARIABLES -> model.variables.isNotEmpty()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package io.miragon.bpmn.adapter.outbound.codegen

/**
* The sections a generated Process API can contain.
*
* A catalogue of names, nothing else. Which of them a given run actually emits is decided by
* [ApiObjectSelection] — that is a question about a model, and one day about what the caller asked for,
* neither of which is a property of the name.
*/
internal enum class ApiObjectType {

PROCESS_ID,
PROCESS_ENGINE,
ELEMENTS,
FLOWS,
RELATIONS,
VARIANTS,
CALL_ACTIVITIES,
MESSAGES,
SERVICE_TASKS,
TIMERS,
ERRORS,
ESCALATIONS,
COMPENSATIONS,
SIGNALS,
VARIABLES,
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import io.miragon.bpmn.domain.BpmnModelApi
import io.miragon.bpmn.domain.GeneratedApiFile
import io.miragon.bpmn.domain.shared.OutputLanguage

class CodeGenerationAdapter(
internal class CodeGenerationAdapter(
private val processApiBuilders: Map<OutputLanguage, AbstractProcessApiBuilder<*>> = Companion.processApiBuilders,
) : GenerateApiCodePort {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package io.miragon.bpmn.adapter.outbound.codegen.builder

import io.miragon.bpmn.domain.shared.VariableMapping

/**
* Registry entries reduced to the constants the Process API can actually declare.
*
* The domain keeps every `bpmn:Definitions` root element, because flow nodes reference them by id and two
* 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() }
}
Loading