Fix HTML export states, endpoint linking, and dynamic trigger event validation.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -33,7 +33,8 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
|
||||
|
||||
List<TriggerPoint> triggers = result.getMetadata().getTriggers();
|
||||
List<TriggerPoint> canonicalTriggers = triggers == null ? null : triggers.stream()
|
||||
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
|
||||
.map(trigger -> MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
|
||||
trigger, machineTypes, context))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<CallChain> callChains = result.getMetadata().getCallChains();
|
||||
@@ -43,8 +44,8 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
|
||||
return chain;
|
||||
}
|
||||
return chain.toBuilder()
|
||||
.triggerPoint(MachineEnumCanonicalizer.canonicalizeTriggerPoint(
|
||||
chain.getTriggerPoint(), machineTypes))
|
||||
.triggerPoint(MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
|
||||
chain.getTriggerPoint(), machineTypes, context))
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@@ -128,6 +128,7 @@ public class AnalysisResult {
|
||||
}
|
||||
click.kamil.springstatemachineexporter.analysis.service.AnalysisResultFinalizer.applyCanonicalization(
|
||||
this,
|
||||
null,
|
||||
new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
@@ -91,10 +92,73 @@ public final class MachineEnumCanonicalizer {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands {@code <SYMBOLIC: EnumType.*>} placeholders into concrete package-canonical enum
|
||||
* constants for the owning machine's event type. Non-matching symbolic entries are dropped.
|
||||
*/
|
||||
public static List<String> expandSymbolicPolymorphicEvents(
|
||||
List<String> polymorphicEvents,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
|
||||
return polymorphicEvents;
|
||||
}
|
||||
if (machineEventTypeFqn == null || machineEventTypeFqn.isBlank()) {
|
||||
return polymorphicEvents;
|
||||
}
|
||||
|
||||
List<String> expanded = new ArrayList<>();
|
||||
for (String pe : polymorphicEvents) {
|
||||
if (pe == null) {
|
||||
continue;
|
||||
}
|
||||
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
|
||||
String symbolicType = pe.substring("<SYMBOLIC: ".length(), pe.length() - 3).trim();
|
||||
if (!enumTypesMatch(machineEventTypeFqn, symbolicType)) {
|
||||
continue;
|
||||
}
|
||||
if (context != null) {
|
||||
List<String> enumValues = context.getEnumValues(stripGenerics(machineEventTypeFqn));
|
||||
if (enumValues != null) {
|
||||
for (String value : enumValues) {
|
||||
String parsed = value;
|
||||
if (parsed.contains(".")) {
|
||||
parsed = parsed.substring(parsed.lastIndexOf('.') + 1);
|
||||
}
|
||||
expanded.add(stripGenerics(machineEventTypeFqn) + "." + parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
expanded.add(pe);
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
public static TriggerPoint canonicalizeAndExpandTriggerPoint(
|
||||
TriggerPoint trigger,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context) {
|
||||
TriggerPoint canonical = canonicalizeTriggerPoint(trigger, machineTypes);
|
||||
if (canonical == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
|
||||
return canonical;
|
||||
}
|
||||
List<String> expanded = expandSymbolicPolymorphicEvents(
|
||||
canonical.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context);
|
||||
if (expanded == canonical.getPolymorphicEvents()) {
|
||||
return canonical;
|
||||
}
|
||||
return canonical.toBuilder().polymorphicEvents(expanded).build();
|
||||
}
|
||||
|
||||
public static String qualifyEventIdentifier(String event, String eventTypeFqn) {
|
||||
if (event == null || eventTypeFqn == null || event.isEmpty()) {
|
||||
return event;
|
||||
}
|
||||
if (isDynamicTriggerExpression(event)) {
|
||||
return denormalizeCorruptedDynamicEvent(event);
|
||||
}
|
||||
if (event.startsWith("<SYMBOLIC: ")) {
|
||||
return event;
|
||||
}
|
||||
@@ -152,9 +216,23 @@ public final class MachineEnumCanonicalizer {
|
||||
}
|
||||
|
||||
private static boolean isWildcardVariable(String eventStr) {
|
||||
if (eventStr == null) {
|
||||
return isDynamicTriggerExpression(eventStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* AST-derived trigger events that must never receive package-canonical enum rewriting
|
||||
* ({@code getType()}, {@code valueOf(...)}, ternary dispatchers, parameter names, etc.).
|
||||
*/
|
||||
public static boolean isDynamicTriggerExpression(String eventStr) {
|
||||
if (eventStr == null || eventStr.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
if (eventStr.startsWith("<SYMBOLIC: ")) {
|
||||
return false;
|
||||
}
|
||||
if (eventStr.contains("${")) {
|
||||
return true;
|
||||
}
|
||||
if (eventStr.equals("event") || eventStr.equals("e")
|
||||
|| eventStr.equals("msg") || eventStr.equals("message")
|
||||
|| eventStr.equals("payload")) {
|
||||
@@ -163,7 +241,64 @@ public final class MachineEnumCanonicalizer {
|
||||
if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) {
|
||||
return true;
|
||||
}
|
||||
return eventStr.contains(" ? ") && eventStr.contains(" : ");
|
||||
if (eventStr.contains(" ? ") && eventStr.contains(" : ")) {
|
||||
return true;
|
||||
}
|
||||
if (eventStr.contains("(") || eventStr.contains(")")) {
|
||||
return true;
|
||||
}
|
||||
if (Character.isLowerCase(eventStr.charAt(0)) && !eventStr.contains(".")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts mistaken package-prefixing on dynamic {@code Type.valueOf(...)} trigger expressions.
|
||||
*/
|
||||
public static String denormalizeCorruptedDynamicEvent(String value) {
|
||||
if (value == null || !value.contains(".valueOf(")) {
|
||||
return value;
|
||||
}
|
||||
return value.replaceAll("(?:[a-z][a-z0-9_]*\\.)+([A-Z][A-Za-z0-9_]*)\\.valueOf\\(", "$1.valueOf(");
|
||||
}
|
||||
|
||||
public enum TriggerEventKind {
|
||||
CANONICAL_ENUM,
|
||||
DYNAMIC_EXPRESSION,
|
||||
SYMBOLIC,
|
||||
LIFECYCLE,
|
||||
OTHER
|
||||
}
|
||||
|
||||
public static TriggerEventKind classifyTriggerEvent(String event) {
|
||||
if (event == null || event.isBlank()) {
|
||||
return TriggerEventKind.OTHER;
|
||||
}
|
||||
if (event.startsWith("<SYMBOLIC: ")) {
|
||||
return TriggerEventKind.SYMBOLIC;
|
||||
}
|
||||
if (click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers.isLifecycle(event)) {
|
||||
return TriggerEventKind.LIFECYCLE;
|
||||
}
|
||||
if (isDynamicTriggerExpression(event)) {
|
||||
return TriggerEventKind.DYNAMIC_EXPRESSION;
|
||||
}
|
||||
if (event.contains(".")) {
|
||||
String constant = constantName(event);
|
||||
if (constant.matches("[A-Z_][A-Z0-9_]*")) {
|
||||
return TriggerEventKind.CANONICAL_ENUM;
|
||||
}
|
||||
}
|
||||
return TriggerEventKind.OTHER;
|
||||
}
|
||||
|
||||
public static boolean hasCorruptedPackageDynamicEvent(String event) {
|
||||
if (event == null || !event.contains(".valueOf(")) {
|
||||
return false;
|
||||
}
|
||||
String denormalized = denormalizeCorruptedDynamicEvent(event);
|
||||
return !event.equals(denormalized);
|
||||
}
|
||||
|
||||
private static List<State> canonicalizeStates(List<State> states, String stateTypeFqn) {
|
||||
@@ -257,6 +392,9 @@ public final class MachineEnumCanonicalizer {
|
||||
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
if (isDynamicTriggerExpression(value)) {
|
||||
return denormalizeCorruptedDynamicEvent(value);
|
||||
}
|
||||
value = resolvePlaceholder(value);
|
||||
if (value == null || value.isBlank()) {
|
||||
return value;
|
||||
|
||||
@@ -43,7 +43,7 @@ public final class AnalysisResultFinalizer {
|
||||
}
|
||||
|
||||
persistMachineTypes(result, machineTypes);
|
||||
applyCanonicalization(result, machineTypes);
|
||||
applyCanonicalizationFields(result, context, machineTypes);
|
||||
|
||||
if (context != null) {
|
||||
AnalysisCanonicalFormValidator.enforce(result, context);
|
||||
@@ -66,17 +66,25 @@ public final class AnalysisResultFinalizer {
|
||||
public static void applyCanonicalization(
|
||||
AnalysisResult result,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
applyCanonicalization(result, null, machineTypes);
|
||||
}
|
||||
|
||||
public static void applyCanonicalization(
|
||||
AnalysisResult result,
|
||||
CodebaseContext context,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
if (result == null || machineTypes == null) {
|
||||
return;
|
||||
}
|
||||
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) {
|
||||
return;
|
||||
}
|
||||
applyCanonicalizationFields(result, machineTypes);
|
||||
applyCanonicalizationFields(result, context, machineTypes);
|
||||
}
|
||||
|
||||
private static void applyCanonicalizationFields(
|
||||
AnalysisResult result,
|
||||
CodebaseContext context,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
MachineEnumCanonicalizer.canonicalizeTransitions(result.getTransitions(), machineTypes);
|
||||
|
||||
@@ -91,8 +99,8 @@ public final class AnalysisResultFinalizer {
|
||||
|
||||
if (result.getMetadata() != null) {
|
||||
CodebaseMetadata metadata = result.getMetadata();
|
||||
List<TriggerPoint> triggers = canonicalizeTriggers(metadata.getTriggers(), machineTypes);
|
||||
List<CallChain> callChains = canonicalizeCallChains(metadata.getCallChains(), machineTypes);
|
||||
List<TriggerPoint> triggers = canonicalizeTriggers(metadata.getTriggers(), context, machineTypes);
|
||||
List<CallChain> callChains = canonicalizeCallChains(metadata.getCallChains(), context, machineTypes);
|
||||
result.setMetadata(CodebaseMetadata.builder()
|
||||
.triggers(triggers)
|
||||
.entryPoints(metadata.getEntryPoints())
|
||||
@@ -104,17 +112,20 @@ public final class AnalysisResultFinalizer {
|
||||
|
||||
private static List<TriggerPoint> canonicalizeTriggers(
|
||||
List<TriggerPoint> triggers,
|
||||
CodebaseContext context,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
if (triggers == null) {
|
||||
return null;
|
||||
}
|
||||
return triggers.stream()
|
||||
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
|
||||
.map(trigger -> MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
|
||||
trigger, machineTypes, context))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static List<CallChain> canonicalizeCallChains(
|
||||
List<CallChain> callChains,
|
||||
CodebaseContext context,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
if (callChains == null) {
|
||||
return null;
|
||||
@@ -123,7 +134,8 @@ public final class AnalysisResultFinalizer {
|
||||
.map(chain -> {
|
||||
TriggerPoint trigger = chain.getTriggerPoint() == null
|
||||
? null
|
||||
: MachineEnumCanonicalizer.canonicalizeTriggerPoint(chain.getTriggerPoint(), machineTypes);
|
||||
: MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
|
||||
chain.getTriggerPoint(), machineTypes, context);
|
||||
List<MatchedTransition> matched = canonicalizeMatchedTransitions(
|
||||
chain.getMatchedTransitions(), machineTypes);
|
||||
return chain.toBuilder()
|
||||
|
||||
@@ -185,6 +185,7 @@ public final class AnalysisCanonicalFormValidator {
|
||||
String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn());
|
||||
|
||||
requireCanonical(prefix + ".event", trigger.getEvent(), eventTypeFqn, violations);
|
||||
validateTriggerEventForm(prefix + ".event", trigger.getEvent(), violations);
|
||||
requireCanonical(prefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations);
|
||||
|
||||
if (trigger.getPolymorphicEvents() != null) {
|
||||
@@ -215,6 +216,7 @@ public final class AnalysisCanonicalFormValidator {
|
||||
String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn());
|
||||
String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn());
|
||||
requireCanonical(triggerPrefix + ".event", trigger.getEvent(), eventTypeFqn, violations);
|
||||
validateTriggerEventForm(triggerPrefix + ".event", trigger.getEvent(), violations);
|
||||
requireCanonical(triggerPrefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations);
|
||||
if (trigger.getPolymorphicEvents() != null) {
|
||||
for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) {
|
||||
@@ -258,4 +260,16 @@ public final class AnalysisCanonicalFormValidator {
|
||||
violations.add(new Violation(path, value, expected));
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateTriggerEventForm(String path, String event, List<Violation> violations) {
|
||||
if (event == null || event.isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (MachineEnumCanonicalizer.hasCorruptedPackageDynamicEvent(event)) {
|
||||
violations.add(new Violation(
|
||||
path,
|
||||
event,
|
||||
MachineEnumCanonicalizer.denormalizeCorruptedDynamicEvent(event)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,10 +63,10 @@ public class Dot implements StateMachineExporter {
|
||||
// Start/End nodes
|
||||
sb.append(" _start [shape=circle, label=\"\", fillcolor=black, width=0.1];\n");
|
||||
for (String start : startStates) {
|
||||
sb.append(" _start -> ").append(simplify(start)).append(";\n");
|
||||
sb.append(" _start -> ").append(simplify(options.formatStateLabel(start))).append(";\n");
|
||||
}
|
||||
for (String end : endStates) {
|
||||
sb.append(" ").append(simplify(end)).append(" [fillcolor=lightgray];\n");
|
||||
sb.append(" ").append(simplify(options.formatStateLabel(end))).append(" [fillcolor=lightgray];\n");
|
||||
}
|
||||
|
||||
// Transitions
|
||||
|
||||
@@ -30,6 +30,12 @@ public class ExportOptions {
|
||||
return format(event.rawName(), event.fullIdentifier(), eventFormat);
|
||||
}
|
||||
|
||||
/** Formats a bare state label (e.g. start/end state string) using the same rules as {@link #formatState}. */
|
||||
public String formatStateLabel(String label) {
|
||||
if (label == null) return null;
|
||||
return format(label, label, stateFormat);
|
||||
}
|
||||
|
||||
private String format(String raw, String fqn, EnumFormat format) {
|
||||
if (fqn == null) return raw;
|
||||
switch (format) {
|
||||
|
||||
@@ -55,7 +55,7 @@ public class PlantUml implements StateMachineExporter {
|
||||
sb.append("\n");
|
||||
|
||||
for (String start : startStates) {
|
||||
sb.append("[*] --> ").append(simplify(start)).append("\n");
|
||||
sb.append("[*] --> ").append(simplify(options.formatStateLabel(start))).append("\n");
|
||||
}
|
||||
sb.append("\n");
|
||||
|
||||
@@ -143,7 +143,7 @@ public class PlantUml implements StateMachineExporter {
|
||||
|
||||
sb.append("\n");
|
||||
for (String end : endStates) {
|
||||
sb.append(simplify(end)).append(" --> [*]\n");
|
||||
sb.append(simplify(options.formatStateLabel(end))).append(" --> [*]\n");
|
||||
}
|
||||
|
||||
sb.append("@enduml\n");
|
||||
|
||||
@@ -26,7 +26,7 @@ public class Scxml implements StateMachineExporter {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
sb.append("<scxml xmlns=\"http://www.w3.org/2005/07/scxml\" version=\"1.0\" initial=\"");
|
||||
sb.append(startStates.isEmpty() ? "" : simplify(startStates.iterator().next()));
|
||||
sb.append(startStates.isEmpty() ? "" : simplify(options.formatStateLabel(startStates.iterator().next())));
|
||||
sb.append("\">\n");
|
||||
|
||||
Set<String> allStates = new java.util.LinkedHashSet<>();
|
||||
@@ -34,8 +34,8 @@ public class Scxml implements StateMachineExporter {
|
||||
t.getSourceStates().forEach(s -> allStates.add(options.formatState(s)));
|
||||
t.getTargetStates().forEach(s -> allStates.add(options.formatState(s)));
|
||||
}
|
||||
allStates.addAll(startStates);
|
||||
allStates.addAll(endStates);
|
||||
startStates.forEach(s -> allStates.add(options.formatStateLabel(s)));
|
||||
endStates.forEach(s -> allStates.add(options.formatStateLabel(s)));
|
||||
|
||||
for (String state : allStates) {
|
||||
sb.append(" <state id=\"").append(simplify(state)).append("\">\n");
|
||||
|
||||
@@ -193,7 +193,7 @@ public class ExportService {
|
||||
result.getStateTypeFqn(),
|
||||
result.getEventTypeFqn(),
|
||||
context);
|
||||
AnalysisResultFinalizer.applyCanonicalization(result, machineTypes);
|
||||
AnalysisResultFinalizer.applyCanonicalization(result, context, machineTypes);
|
||||
enrichmentService.relinkAfterPropertyResolution(result, context, null);
|
||||
AnalysisResultFinalizer.finalizeResult(result, context, machineTypes);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user