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

@@ -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");