Support structured source/event steps in business flows

Add FlowStep with backward-compatible JSON (strings or objects), precompute linkKey in HTML export, and document precise flow highlighting in flows.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 06:48:33 +02:00
parent 00d1f24709
commit 24fe8dfe00
11 changed files with 252 additions and 19 deletions

View File

@@ -56,11 +56,17 @@ Define sequence of events in `src/main/resources/flows.json`:
{
"name": "Order Success",
"description": "Happy path for order placement",
"steps": ["PAY", "CHECK_AVAILABILITY->PENDING", "SHIP"]
"steps": [
"PAY",
"CHECK_AVAILABILITY->PENDING",
{ "source": "com.example.order.OrderState.PAID", "event": "com.example.order.OrderEvent.SHIP" }
]
}
]
```
Event-only strings remain supported but do not highlight in the HTML explorer unless paired with a source state. Use `{source, event}` objects for precise transition highlighting.
## JSON Structure
- `metadata.entryPoints`: REST, WebFlux, and JMS entry points.
- `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`).

View File

@@ -8,11 +8,11 @@ import lombok.extern.jackson.Jacksonized;
import java.util.List;
@Data
@Builder
@Builder(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class BusinessFlow {
private final String name;
private final String description;
private final List<String> steps; // List of Event names in order
private final List<FlowStep> steps;
}

View File

@@ -0,0 +1,29 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import lombok.Builder;
import lombok.Data;
@Data
@Builder(toBuilder = true)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonDeserialize(using = FlowStepDeserializer.class)
@JsonSerialize(using = FlowStepSerializer.class)
public class FlowStep {
/** Source state identifier (package-canonical FQN or short form). */
@JsonInclude(JsonInclude.Include.NON_NULL)
private final String source;
/** Event identifier or {@code Source->Target} for anonymous transitions. */
@JsonInclude(JsonInclude.Include.NON_NULL)
private final String event;
/** Precomputed {@code #link_*} suffix for HTML/SVG highlight. */
@JsonInclude(JsonInclude.Include.NON_NULL)
private final String linkKey;
public static FlowStep ofEvent(String event) {
return FlowStep.builder().event(event).build();
}
}

View File

@@ -0,0 +1,33 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
class FlowStepDeserializer extends JsonDeserializer<FlowStep> {
@Override
public FlowStep deserialize(JsonParser parser, DeserializationContext context) throws IOException {
JsonNode node = parser.getCodec().readTree(parser);
if (node.isTextual()) {
return FlowStep.builder().event(node.asText()).build();
}
return FlowStep.builder()
.source(textOrNull(node, "source"))
.event(textOrNull(node, "event"))
.linkKey(textOrNull(node, "linkKey"))
.build();
}
private static String textOrNull(JsonNode node, String field) {
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
String text = value.asText();
return text.isBlank() ? null : text;
}
}

View File

@@ -0,0 +1,35 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
class FlowStepSerializer extends JsonSerializer<FlowStep> {
@Override
public void serialize(FlowStep step, JsonGenerator generator, SerializerProvider serializers) throws IOException {
if (step == null) {
generator.writeNull();
return;
}
boolean structured = step.getSource() != null && !step.getSource().isBlank()
|| step.getLinkKey() != null && !step.getLinkKey().isBlank();
if (!structured && step.getEvent() != null && !step.getEvent().isBlank()) {
generator.writeString(step.getEvent());
return;
}
generator.writeStartObject();
if (step.getSource() != null && !step.getSource().isBlank()) {
generator.writeStringField("source", step.getSource());
}
if (step.getEvent() != null && !step.getEvent().isBlank()) {
generator.writeStringField("event", step.getEvent());
}
if (step.getLinkKey() != null && !step.getLinkKey().isBlank()) {
generator.writeStringField("linkKey", step.getLinkKey());
}
generator.writeEndObject();
}
}

View File

@@ -0,0 +1,46 @@
package click.kamil.springstatemachineexporter.analysis.model;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class FlowStepJsonTest {
private final com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
@Test
void shouldDeserializeLegacyStringStep() throws Exception {
FlowStep step = mapper.readValue("\"PAY\"", FlowStep.class);
assertThat(step.getEvent()).isEqualTo("PAY");
assertThat(step.getSource()).isNull();
}
@Test
void shouldDeserializeStructuredStep() throws Exception {
FlowStep step = mapper.readValue(
"{\"source\":\"com.example.order.OrderState.PAID\",\"event\":\"com.example.order.OrderEvent.SHIP\"}",
FlowStep.class);
assertThat(step.getSource()).isEqualTo("com.example.order.OrderState.PAID");
assertThat(step.getEvent()).isEqualTo("com.example.order.OrderEvent.SHIP");
}
@Test
void shouldSerializeEventOnlyStepAsString() throws Exception {
String json = mapper.writeValueAsString(FlowStep.ofEvent("PAY"));
assertThat(json).isEqualTo("\"PAY\"");
}
@Test
void shouldSerializeStructuredStepAsObject() throws Exception {
FlowStep step = FlowStep.builder()
.source("com.example.order.OrderState.PAID")
.event("com.example.order.OrderEvent.SHIP")
.linkKey("OrderState_PAID__OrderEvent_SHIP")
.build();
String json = mapper.writeValueAsString(step);
assertThat(json).contains("\"source\"");
assertThat(json).contains("\"event\"");
assertThat(json).contains("\"linkKey\"");
}
}

View File

@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.exporter;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
import click.kamil.springstatemachineexporter.analysis.model.FlowStep;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
@@ -35,7 +36,7 @@ class JsonInterchangeContractTest {
.startStates(Set.of("com.example.order.OrderState.NEW"))
.endStates(Set.of("com.example.order.OrderState.PAID"))
.renderChoicesAsDiamonds(true)
.flows(List.of(BusinessFlow.builder().name("Pay flow").steps(List.of("PAY")).build()))
.flows(List.of(BusinessFlow.builder().name("Pay flow").steps(List.of(FlowStep.ofEvent("PAY"))).build()))
.metadata(CodebaseMetadata.empty())
.build();

View File

@@ -1,8 +1,10 @@
package click.kamil.springstatemachineexporter.html.exporter;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.FlowStep;
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
@@ -152,16 +154,26 @@ public class HtmlExporter implements StateMachineExporter {
}
private static AnalysisResult enrichLinkKeys(AnalysisResult result, ExportOptions options) {
AnalysisResult.AnalysisResultBuilder builder = result.toBuilder();
boolean changed = false;
CodebaseMetadata metadata = result.getMetadata();
if (metadata == null || metadata.getCallChains() == null || metadata.getCallChains().isEmpty()) {
return result;
if (metadata != null && metadata.getCallChains() != null && !metadata.getCallChains().isEmpty()) {
List<CallChain> enrichedChains = metadata.getCallChains().stream()
.map(chain -> enrichChainLinkKeys(chain, options))
.toList();
builder.metadata(metadata.toBuilder().callChains(enrichedChains).build());
changed = true;
}
List<CallChain> enrichedChains = metadata.getCallChains().stream()
.map(chain -> enrichChainLinkKeys(chain, options))
.toList();
return result.toBuilder()
.metadata(metadata.toBuilder().callChains(enrichedChains).build())
.build();
if (result.getFlows() != null && !result.getFlows().isEmpty()) {
builder.flows(result.getFlows().stream()
.map(flow -> enrichFlowLinkKeys(flow, options))
.toList());
changed = true;
}
return changed ? builder.build() : result;
}
private static CallChain enrichChainLinkKeys(CallChain chain, ExportOptions options) {
@@ -186,6 +198,33 @@ public class HtmlExporter implements StateMachineExporter {
return mt.toBuilder().linkKey(linkKey).build();
}
private static BusinessFlow enrichFlowLinkKeys(BusinessFlow flow, ExportOptions options) {
if (flow.getSteps() == null || flow.getSteps().isEmpty()) {
return flow;
}
List<FlowStep> enrichedSteps = flow.getSteps().stream()
.map(step -> enrichFlowStepLinkKey(step, options))
.toList();
return flow.toBuilder().steps(enrichedSteps).build();
}
private static FlowStep enrichFlowStepLinkKey(FlowStep step, ExportOptions options) {
if (step.getLinkKey() != null && !step.getLinkKey().isBlank()) {
return step;
}
if (step.getSource() == null || step.getSource().isBlank()
|| step.getEvent() == null || step.getEvent().isBlank()
|| step.getEvent().contains("->")) {
return step;
}
String linkKey = TransitionLinkKey.build(
step.getSource(),
step.getEvent(),
options.getStateFormat(),
options.getEventFormat());
return step.toBuilder().linkKey(linkKey).build();
}
private String loadTemplate() {
try (InputStream is = getClass().getResourceAsStream("/html/template.html")) {
if (is == null) throw new RuntimeException("HTML template not found");

View File

@@ -531,10 +531,14 @@
if (typeof step === 'object') {
targetLinkKey = step.linkKey || buildLinkKey(step.source, step.event);
isAmbiguous = step.ambiguous;
} else if (step.includes("->")) {
const parts = step.split("->");
targetAnon = "#link_anon_" + normalize(parts[0]) + "_" + normalize(parts[1]);
} else {
if (!targetLinkKey && step.event && step.event.includes('->')) {
const parts = step.event.split('->');
targetAnon = '#link_anon_' + normalize(parts[0]) + '_' + normalize(parts[1]);
}
} else if (typeof step === 'string' && step.includes('->')) {
const parts = step.split('->');
targetAnon = '#link_anon_' + normalize(parts[0]) + '_' + normalize(parts[1]);
} else if (typeof step === 'string') {
// Event-only string steps without source are not precise enough to highlight.
return;
}

View File

@@ -6,6 +6,7 @@ import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
import click.kamil.springstatemachineexporter.analysis.model.FlowStep;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
@@ -49,7 +50,7 @@ public class HtmlExporterTest {
// 3. Documented Flows
List<BusinessFlow> flows = List.of(
BusinessFlow.builder().name("Test Flow").description("Desc").steps(List.of("CREATE")).build()
BusinessFlow.builder().name("Test Flow").description("Desc").steps(List.of(FlowStep.ofEvent("CREATE"))).build()
);
AnalysisResult result = AnalysisResult.builder()

View File

@@ -1,9 +1,11 @@
package click.kamil.springstatemachineexporter.html;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.FlowStep;
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
@@ -134,10 +136,10 @@ class HtmlTransitionLinkConsistencyTest {
"com.example.order.OrderState.SHIPPED")))
.startStates(Set.of("com.example.order.OrderState.NEW"))
.endStates(Set.of("com.example.order.OrderState.SHIPPED"))
.flows(List.of(click.kamil.springstatemachineexporter.analysis.model.BusinessFlow.builder()
.flows(List.of(BusinessFlow.builder()
.name("Ship only")
.description("Should not light PAY")
.steps(List.of("SHIP"))
.steps(List.of(FlowStep.ofEvent("SHIP")))
.build()))
.build();
@@ -147,6 +149,43 @@ class HtmlTransitionLinkConsistencyTest {
assertThat(html).doesNotContain("match that event from any source");
}
@Test
void flowHighlightShouldUseStructuredSourceAndEventSteps(@TempDir Path tempDir) throws Exception {
writeSampleProject(tempDir);
AnalysisResult finalizedShape = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(
shortTransition(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderState.NEW",
"com.example.order.OrderState.PAID"),
shortTransition(
"com.example.order.OrderEvent.SHIP",
"com.example.order.OrderState.PAID",
"com.example.order.OrderState.SHIPPED")))
.startStates(Set.of("com.example.order.OrderState.NEW"))
.endStates(Set.of("com.example.order.OrderState.SHIPPED"))
.flows(List.of(BusinessFlow.builder()
.name("Ship from paid")
.description("Highlights SHIP only")
.steps(List.of(FlowStep.builder()
.source("com.example.order.OrderState.PAID")
.event("com.example.order.OrderEvent.SHIP")
.build()))
.build()))
.build();
String html = new HtmlExporter().export(finalizedShape, ExportOptions.builder().build());
assertThat(html).contains("#link_OrderState_PAID__OrderEvent_SHIP");
assertThat(html).contains("\"linkKey\" : \"OrderState_PAID__OrderEvent_SHIP\"");
assertThat(html).contains("\"source\" : \"com.example.order.OrderState.PAID\"");
assertThat(html).contains("\"event\" : \"com.example.order.OrderEvent.SHIP\"");
}
@Test
void layeredDispatcherHtmlShouldContainLinkKeysForEveryMatchedTransition(@TempDir Path tempDir) throws Exception {
Path sampleRoot = findProjectRoot().resolve("state_machines/layered_dispatcher_sample");