Precompute matched-transition link keys for HTML export

Embed linkKey on matchedTransitions in HTML metadata so explorer highlighting uses the same SVG #link_* suffix as PlantUML, avoiding JS/Java format drift while keeping golden JSON unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 06:46:23 +02:00
parent 32fe96041c
commit 00d1f24709
6 changed files with 142 additions and 5 deletions

View File

@@ -1,16 +1,20 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
@Data
@Builder
@Builder(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class MatchedTransition {
private final String sourceState;
private final String targetState;
private final String event;
/** Precomputed {@code #link_*} suffix for HTML/SVG highlight; optional in JSON exports. */
@JsonInclude(JsonInclude.Include.NON_NULL)
private final String linkKey;
}

View File

@@ -0,0 +1,60 @@
package click.kamil.springstatemachineexporter.exporter;
/**
* Builds SVG/HTML transition link keys ({@code Source__Event}) using the same formatting rules as
* {@link PlantUml} embedded identifiers and the HTML explorer {@code buildLinkKey} helper.
*/
public final class TransitionLinkKey {
private TransitionLinkKey() {
}
public static String build(String sourceStateIdentifier, String eventIdentifier) {
return build(sourceStateIdentifier, eventIdentifier, EnumFormat.fn, EnumFormat.fn);
}
public static String build(
String sourceStateIdentifier,
String eventIdentifier,
EnumFormat stateFormat,
EnumFormat eventFormat) {
if (eventIdentifier == null || eventIdentifier.isBlank()) {
return "";
}
String formattedEvent = formatIdentifier(eventIdentifier, eventFormat);
if (sourceStateIdentifier == null || sourceStateIdentifier.isBlank()) {
return "__" + normalize(formattedEvent);
}
return normalize(formatIdentifier(sourceStateIdentifier, stateFormat))
+ "__"
+ normalize(formattedEvent);
}
static String formatIdentifier(String identifier, EnumFormat format) {
if (identifier == null || identifier.isBlank()) {
return "";
}
return switch (format) {
case fqn -> identifier;
case sn -> {
int lastDot = identifier.lastIndexOf('.');
yield lastDot >= 0 ? identifier.substring(lastDot + 1) : identifier;
}
case fn -> {
int lastDot = identifier.lastIndexOf('.');
if (lastDot <= 0) {
yield identifier;
}
int prevDot = identifier.lastIndexOf('.', lastDot - 1);
yield prevDot > 0 ? identifier.substring(prevDot + 1) : identifier;
}
};
}
static String normalize(String value) {
if (value == null || value.isBlank()) {
return "";
}
return value.replaceAll("[^a-zA-Z0-9]", "_");
}
}

View File

@@ -0,0 +1,32 @@
package click.kamil.springstatemachineexporter.exporter;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TransitionLinkKeyTest {
@Test
void shouldBuildFnFormLinkKeyFromPackageCanonicalIdentifiers() {
assertThat(TransitionLinkKey.build(
"com.example.order.OrderState.NEW",
"com.example.order.OrderEvent.PAY"))
.isEqualTo("OrderState_NEW__OrderEvent_PAY");
}
@Test
void shouldBuildFqnFormLinkKeyWhenRequested() {
assertThat(TransitionLinkKey.build(
"com.example.order.OrderState.NEW",
"com.example.order.OrderEvent.PAY",
EnumFormat.fqn,
EnumFormat.fqn))
.isEqualTo("com_example_order_OrderState_NEW__com_example_order_OrderEvent_PAY");
}
@Test
void shouldBuildEventOnlyLinkKeyWhenSourceMissing() {
assertThat(TransitionLinkKey.build(null, "com.example.order.OrderEvent.PAY"))
.isEqualTo("__OrderEvent_PAY");
}
}

View File

@@ -2,11 +2,14 @@ package click.kamil.springstatemachineexporter.html.exporter;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
import click.kamil.springstatemachineexporter.exporter.PlantUml;
import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
import click.kamil.springstatemachineexporter.exporter.TransitionLinkKey;
import click.kamil.springstatemachineexporter.model.Transition;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
@@ -58,8 +61,9 @@ public class HtmlExporter implements StateMachineExporter {
// 4. Load Template
String template = loadTemplate();
// 5. Prepare Metadata JSON
String metadataJson = objectMapper.writeValueAsString(result);
// 5. Prepare Metadata JSON (precompute link keys so HTML JS matches SVG identifiers)
AnalysisResult htmlMetadata = enrichLinkKeys(result, options);
String metadataJson = objectMapper.writeValueAsString(htmlMetadata);
// Check if we should render metadata pane
boolean hideMetadataProp = Boolean.getBoolean("html.hideMetadataPane");
@@ -147,6 +151,41 @@ public class HtmlExporter implements StateMachineExporter {
return false;
}
private static AnalysisResult enrichLinkKeys(AnalysisResult result, ExportOptions options) {
CodebaseMetadata metadata = result.getMetadata();
if (metadata == null || metadata.getCallChains() == null || metadata.getCallChains().isEmpty()) {
return result;
}
List<CallChain> enrichedChains = metadata.getCallChains().stream()
.map(chain -> enrichChainLinkKeys(chain, options))
.toList();
return result.toBuilder()
.metadata(metadata.toBuilder().callChains(enrichedChains).build())
.build();
}
private static CallChain enrichChainLinkKeys(CallChain chain, ExportOptions options) {
if (chain.getMatchedTransitions() == null || chain.getMatchedTransitions().isEmpty()) {
return chain;
}
List<MatchedTransition> enriched = chain.getMatchedTransitions().stream()
.map(mt -> enrichMatchedTransitionLinkKey(mt, options))
.toList();
return chain.toBuilder().matchedTransitions(enriched).build();
}
private static MatchedTransition enrichMatchedTransitionLinkKey(MatchedTransition mt, ExportOptions options) {
if (mt.getLinkKey() != null && !mt.getLinkKey().isBlank()) {
return mt;
}
String linkKey = TransitionLinkKey.build(
mt.getSourceState(),
mt.getEvent(),
options.getStateFormat(),
options.getEventFormat());
return mt.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

@@ -492,12 +492,13 @@
let steps = [];
chains.forEach(c => {
c.matchedTransitions.forEach(t => {
const key = buildLinkKey(t.sourceState, t.event);
const key = t.linkKey || buildLinkKey(t.sourceState, t.event);
if (!key || seen.has(key)) return;
seen.add(key);
steps.push({
event: t.event,
source: t.sourceState,
linkKey: t.linkKey,
ambiguous: c.linkResolution === 'AMBIGUOUS_WIDEN'
|| (c.triggerPoint && c.triggerPoint.ambiguous)
});
@@ -528,7 +529,7 @@
let isAmbiguous = false;
if (typeof step === 'object') {
targetLinkKey = buildLinkKey(step.source, step.event);
targetLinkKey = step.linkKey || buildLinkKey(step.source, step.event);
isAmbiguous = step.ambiguous;
} else if (step.includes("->")) {
const parts = step.split("->");

View File

@@ -412,6 +412,7 @@ class HtmlTransitionLinkConsistencyTest {
assertThat(html).contains("Ambiguous link");
assertThat(html).contains("#link_OrderState_NEW__OrderEvent_PAY");
assertThat(html).contains("\"linkKey\" : \"OrderState_NEW__OrderEvent_PAY\"");
assertThat(html).contains("com.example.web.AmbiguousController");
}