Enforce package-canonical enum identifiers across analysis export.

Resolve generic configurer type arguments, canonicalize transitions and triggers consistently, validate after property resolution, and update regression goldens.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 00:48:43 +02:00
parent ea9b8d6cff
commit 0aca0aade9
116 changed files with 3561 additions and 2727 deletions

View File

@@ -0,0 +1,31 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.validation.AnalysisCanonicalFormValidator;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
/**
* Final enrichment step: fail fast when enum identifiers in the analysis model are not
* package-canonical. Disable with {@code -Danalyzer.canonical-form-validation.enabled=false}.
*/
@Slf4j
public class AnalysisCanonicalFormEnricher implements AnalysisEnricher {
private final boolean enabled;
public AnalysisCanonicalFormEnricher() {
this.enabled = Boolean.parseBoolean(
System.getProperty("analyzer.canonical-form-validation.enabled", "true"));
}
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (!enabled || result == null || context == null) {
return;
}
log.debug("Validating canonical enum identifiers for {}", result.getName());
AnalysisCanonicalFormValidator.enforce(result, context);
}
}

View File

@@ -3,11 +3,15 @@ package click.kamil.springstatemachineexporter.analysis.enricher;
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.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
public class CallChainEnricher implements AnalysisEnricher {
@@ -15,13 +19,28 @@ public class CallChainEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with call chains", result.getName());
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
List<TriggerPoint> canonicalTriggers = intelligence.findTriggerPoints().stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
.collect(Collectors.toList());
List<CallChain> chains = intelligence.findCallChains(
result.getMetadata().getEntryPoints(),
intelligence.findTriggerPoints()
canonicalTriggers
);
chains = chains.stream()
.map(chain -> chain.getTriggerPoint() == null
? chain
: chain.toBuilder()
.triggerPoint(MachineEnumCanonicalizer.canonicalizeTriggerPoint(
chain.getTriggerPoint(), machineTypes))
.build())
.collect(Collectors.toList());
chains = MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context);
result.addMetadata(CodebaseMetadata.builder()
.callChains(chains)
.build());

View File

@@ -15,17 +15,14 @@ import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
public class TransitionLinkerEnricher implements AnalysisEnricher {
private static final ExportOptions LINK_FORMAT_OPTIONS = ExportOptions.builder().build();
private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
private final EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine();
@@ -52,9 +49,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
String smEventForLink = formatEventForLink(t.getEvent());
String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) {
String smSourceForLink = formatSourceForLink(smSourceState);
String smSourceForLink = canonicalState(smSourceState);
String smSource = simplify(smSourceForLink);
if (triggerSource == null || triggerSource.equals(smSource)) {
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
@@ -69,7 +66,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
} else {
for (State smTargetState : t.getTargetStates()) {
String targetForLink = formatSourceForLink(smTargetState);
String targetForLink = canonicalState(smTargetState);
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceForLink)
.targetState(targetForLink)
@@ -162,20 +159,18 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return simplified;
}
private String formatSourceForLink(State state) {
if (state == null) return null;
return simplifyForLink(LINK_FORMAT_OPTIONS.formatState(state));
private String canonicalState(State state) {
if (state == null) {
return null;
}
return state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName();
}
private String formatEventForLink(click.kamil.springstatemachineexporter.model.Event event) {
if (event == null) return null;
return LINK_FORMAT_OPTIONS.formatEvent(event);
}
private String simplifyForLink(String name) {
if (name == null) return "";
if (name.contains("\"")) return name;
return name.replaceAll("[^a-zA-Z0-9_.]", "");
private String canonicalEvent(click.kamil.springstatemachineexporter.model.Event event) {
if (event == null) {
return null;
}
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
}
private boolean isGuardedContains(String smEvent, String triggerEvent) {

View File

@@ -0,0 +1,63 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
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.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.stream.Collectors;
/**
* Canonicalizes trigger-side enum identifiers ({@code event}, {@code polymorphicEvents},
* {@code sourceState}) using the owning machine config's {@code <State, Event>} types.
*/
@Slf4j
public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || context == null) {
return;
}
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
List<TriggerPoint> triggers = result.getMetadata().getTriggers();
List<TriggerPoint> canonicalTriggers = triggers == null ? null : triggers.stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
.collect(Collectors.toList());
List<CallChain> callChains = result.getMetadata().getCallChains();
List<CallChain> canonicalChains = callChains == null ? null : callChains.stream()
.map(chain -> {
if (chain.getTriggerPoint() == null) {
return chain;
}
return chain.toBuilder()
.triggerPoint(MachineEnumCanonicalizer.canonicalizeTriggerPoint(
chain.getTriggerPoint(), machineTypes))
.build();
})
.collect(Collectors.toList());
if (canonicalTriggers == null && canonicalChains == null) {
return;
}
log.debug("Canonicalized triggers for {}", result.getName());
result.setMetadata(CodebaseMetadata.builder()
.triggers(canonicalTriggers != null ? canonicalTriggers : triggers)
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(canonicalChains != null ? canonicalChains : callChains)
.properties(result.getMetadata().getProperties())
.build());
}
}

View File

@@ -2,11 +2,14 @@ package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
public class TriggerEnricher implements AnalysisEnricher {
@@ -14,8 +17,13 @@ public class TriggerEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with triggers", result.getName());
List<TriggerPoint> triggers = intelligence.findTriggerPoints();
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
List<TriggerPoint> triggers = intelligence.findTriggerPoints().stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
.collect(Collectors.toList());
triggers = MachineScopeFilter.filterTriggersForMachine(triggers, result.getName(), context);
result.addMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()

View File

@@ -12,18 +12,18 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
return false;
}
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
String rawTriggerEvent = triggerPoint.getEvent();
if (triggerPoint.isExternal()) {
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null
? triggerPoint.getPolymorphicEvents()
: java.util.Collections.emptyList();
if (polyEvents.isEmpty()) {
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
return false;
}
}
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
String rawTriggerEvent = triggerPoint.getEvent();
if (rawTriggerEvent != null && rawTriggerEvent.startsWith("<SYMBOLIC: ") && rawTriggerEvent.endsWith(".*>")) {
return false;
}
@@ -150,9 +150,13 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
}
if (eventStr.contains(".")) {
String constantPart = eventStr.substring(eventStr.lastIndexOf('.') + 1);
if (!constantPart.isEmpty() && Character.isUpperCase(constantPart.charAt(0))) {
return false;
}
String firstPart = eventStr.substring(0, eventStr.indexOf('.'));
if (!firstPart.isEmpty() && Character.isLowerCase(firstPart.charAt(0))) {
return true;
return true;
}
return false;
}

View File

@@ -1,6 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.TypeDeclaration;
@@ -16,17 +17,18 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn();
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn();
if (triggerEventFqn != null || triggerStateFqn != null) {
String[] machineTypes = getStateMachineTypeArguments(currentMachineName, context);
String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context);
String machineStateFqn = machineTypes[0];
String machineEventFqn = machineTypes[1];
if (triggerEventFqn != null && machineEventFqn != null) {
if (eraseGenerics(triggerEventFqn).equals(eraseGenerics(machineEventFqn))) {
return true; // Match!
return true;
}
if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
return false; // Mismatch!
return false;
}
return false;
}
if (triggerStateFqn != null && machineStateFqn != null) {
if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) {
@@ -298,59 +300,6 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return null;
}
private String[] getStateMachineTypeArguments(String machineName, CodebaseContext context) {
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration(machineName);
if (td != null) {
org.eclipse.jdt.core.dom.Type superclass = td.getSuperclassType();
Set<String> visited = new HashSet<>();
visited.add(machineName);
return findTypeArgumentsRecursively(superclass, context, (org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot(), visited);
}
return new String[]{null, null};
}
private String[] findTypeArgumentsRecursively(org.eclipse.jdt.core.dom.Type type, CodebaseContext context, org.eclipse.jdt.core.dom.CompilationUnit cu, Set<String> visited) {
if (type == null) return new String[]{null, null};
if (type instanceof org.eclipse.jdt.core.dom.ParameterizedType pt) {
String rawTypeName = pt.getType().toString();
if (rawTypeName.equals("StateMachineConfigurerAdapter") || rawTypeName.equals("EnumStateMachineConfigurerAdapter") ||
rawTypeName.endsWith(".StateMachineConfigurerAdapter") || rawTypeName.endsWith(".EnumStateMachineConfigurerAdapter")) {
java.util.List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
String stateType = resolveTypeToFqn((org.eclipse.jdt.core.dom.Type) typeArgs.get(0), cu, context);
String eventType = resolveTypeToFqn((org.eclipse.jdt.core.dom.Type) typeArgs.get(1), cu, context);
return new String[]{stateType, eventType};
}
}
// Recurse into the raw type declaration's superclass
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(rawTypeName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
org.eclipse.jdt.core.dom.Type superclass = (td instanceof org.eclipse.jdt.core.dom.TypeDeclaration) ? ((org.eclipse.jdt.core.dom.TypeDeclaration) td).getSuperclassType() : null;
String[] res = findTypeArgumentsRecursively(superclass, context, (org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot(), visited);
if (res[0] != null || res[1] != null) return res;
}
}
} else {
// It's a simple type (e.g. MyBaseConfig)
String simpleName = type.toString();
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
org.eclipse.jdt.core.dom.Type superclass = (td instanceof org.eclipse.jdt.core.dom.TypeDeclaration) ? ((org.eclipse.jdt.core.dom.TypeDeclaration) td).getSuperclassType() : null;
String[] res = findTypeArgumentsRecursively(superclass, context, (org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot(), visited);
if (res[0] != null || res[1] != null) return res;
}
}
}
return new String[]{null, null};
}
private String eraseGenerics(String type) {
if (type == null) return null;
int idx = type.indexOf('<');
@@ -360,43 +309,6 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return type;
}
private String resolveTypeToFqn(org.eclipse.jdt.core.dom.Type type, org.eclipse.jdt.core.dom.CompilationUnit cu, CodebaseContext context) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((org.eclipse.jdt.core.dom.SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((org.eclipse.jdt.core.dom.ParameterizedType) type).getType(), cu, context);
} else {
simpleName = type.toString();
}
if (simpleName.contains("<")) {
simpleName = simpleName.substring(0, simpleName.indexOf('<'));
}
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
for (Object impObj : cu.imports()) {
org.eclipse.jdt.core.dom.ImportDeclaration imp = (org.eclipse.jdt.core.dom.ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
org.eclipse.jdt.core.dom.AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) return context.getFqn(localTd);
}
return simpleName;
}
private boolean isTypeMismatched(String type1, String type2) {
if (type1 == null || type2 == null) return false;
type1 = eraseGenerics(type1);

View File

@@ -3,6 +3,10 @@ package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Transition;
import lombok.Builder;
import lombok.Getter;
@@ -72,15 +76,18 @@ public class AnalysisResult {
}
}
// 4. Resolve metadata (triggers, entry points)
// 4. Resolve metadata (triggers, entry points, call chains)
if (metadata != null) {
if (metadata.getTriggers() != null) {
for (var trigger : metadata.getTriggers()) {
if (trigger.getEvent() != null) {
trigger.setEvent(resolver.resolveValue(trigger.getEvent(), properties));
}
}
}
List<TriggerPoint> resolvedTriggers = metadata.getTriggers() == null ? null
: metadata.getTriggers().stream()
.map(trigger -> resolveTrigger(trigger, resolver, properties))
.collect(java.util.stream.Collectors.toList());
List<CallChain> resolvedCallChains = metadata.getCallChains() == null ? null
: metadata.getCallChains().stream()
.map(chain -> resolveCallChain(chain, resolver, properties))
.collect(java.util.stream.Collectors.toList());
if (metadata.getEntryPoints() != null) {
for (var ep : metadata.getEntryPoints()) {
if (ep.getName() != null) {
@@ -95,9 +102,54 @@ public class AnalysisResult {
}
}
}
this.metadata = CodebaseMetadata.builder()
.triggers(resolvedTriggers != null ? resolvedTriggers : metadata.getTriggers())
.entryPoints(metadata.getEntryPoints())
.callChains(resolvedCallChains != null ? resolvedCallChains : metadata.getCallChains())
.properties(metadata.getProperties())
.build();
}
}
private static TriggerPoint resolveTrigger(
TriggerPoint trigger,
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
Map<String, String> properties) {
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream()
.map(value -> resolver.resolveValue(value, properties))
.collect(java.util.stream.Collectors.toList());
return trigger.toBuilder()
.event(trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null)
.sourceState(trigger.getSourceState() != null
? resolver.resolveValue(trigger.getSourceState(), properties)
: null)
.polymorphicEvents(polymorphicEvents)
.build();
}
private static CallChain resolveCallChain(
CallChain chain,
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
Map<String, String> properties) {
TriggerPoint trigger = chain.getTriggerPoint() == null
? null
: resolveTrigger(chain.getTriggerPoint(), resolver, properties);
List<MatchedTransition> matchedTransitions = chain.getMatchedTransitions() == null ? null
: chain.getMatchedTransitions().stream()
.map(matched -> MatchedTransition.builder()
.event(resolver.resolveValue(matched.getEvent(), properties))
.sourceState(resolver.resolveValue(matched.getSourceState(), properties))
.targetState(resolver.resolveValue(matched.getTargetState(), properties))
.build())
.collect(java.util.stream.Collectors.toList());
return chain.toBuilder()
.triggerPoint(trigger)
.matchedTransitions(matchedTransitions)
.build();
}
public void addMetadata(CodebaseMetadata newMetadata) {
if (newMetadata == null) return;

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.model;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
@@ -49,14 +50,14 @@ public class TriggerPoint {
this.sourceFile = sourceFile;
this.sourceModule = sourceModule;
this.stateMachineId = stateMachineId;
this.sourceState = sourceState;
this.sourceState = MachineEnumCanonicalizer.canonicalizeLabel(sourceState, stateTypeFqn);
this.lineNumber = lineNumber;
this.stateTypeFqn = stateTypeFqn;
this.eventTypeFqn = eventTypeFqn;
this.event = qualifyEvent(event, eventTypeFqn);
this.event = MachineEnumCanonicalizer.qualifyEventIdentifier(event, eventTypeFqn);
if (polymorphicEvents != null) {
this.polymorphicEvents = polymorphicEvents.stream()
.map(pe -> qualifyEvent(pe, eventTypeFqn))
.map(pe -> MachineEnumCanonicalizer.qualifyEventIdentifier(pe, eventTypeFqn))
.collect(java.util.stream.Collectors.toList());
} else {
this.polymorphicEvents = null;
@@ -65,66 +66,4 @@ public class TriggerPoint {
this.constraint = constraint;
this.ambiguous = ambiguous;
}
private static String qualifyEvent(String e, String eventTypeFqn) {
if (e == null || eventTypeFqn == null || e.isEmpty()) {
return e;
}
if (e.startsWith("<SYMBOLIC: ")) {
return e;
}
if (isWildcardVariable(e)) {
return e;
}
if (Character.isLowerCase(e.charAt(0))) {
return e;
}
if (!isEnumConstantCandidate(e)) {
return e;
}
String simpleEventType = eventTypeFqn.contains(".") ? eventTypeFqn.substring(eventTypeFqn.lastIndexOf('.') + 1) : eventTypeFqn;
if (e.contains(".")) {
String qualifier = e.substring(0, e.lastIndexOf('.'));
String lastSegment = e.substring(e.lastIndexOf('.') + 1);
String simpleQualifier = qualifier.contains(".") ? qualifier.substring(qualifier.lastIndexOf('.') + 1) : qualifier;
if (simpleQualifier.equals(simpleEventType)) {
return simpleEventType + "." + lastSegment;
}
return e;
}
if (eventTypeFqn.startsWith("java.lang.") || eventTypeFqn.equals("String") || eventTypeFqn.equals("int") || eventTypeFqn.equals("long") || eventTypeFqn.equals("char")) {
return e;
}
return simpleEventType + "." + e;
}
private static boolean isEnumConstantCandidate(String eventStr) {
if (eventStr == null) return false;
if (eventStr.startsWith("<SYMBOLIC: ")) {
return false;
}
if (eventStr.contains(" ") || eventStr.contains("(") || eventStr.contains(")") || eventStr.contains("?")) {
return false;
}
return true;
}
private static boolean isWildcardVariable(String eventStr) {
if (eventStr == null) return false;
if (eventStr.equals("event") || eventStr.equals("e") ||
eventStr.equals("msg") || eventStr.equals("message") ||
eventStr.equals("payload")) {
return true;
}
if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) {
return true;
}
if (eventStr.contains(" ? ") && eventStr.contains(" : ")) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,301 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Canonicalizes enum-backed state/event identifiers for analysis using the owning machine config's
* {@code StateMachineConfigurerAdapter<State, Event>} type parameters.
*
* <p>Display formatting ({@code --event}/{@code --state}) is separate; this fixes internal
* {@code fullIdentifier} values used for matching and traceability.
*/
public final class MachineEnumCanonicalizer {
private MachineEnumCanonicalizer() {
}
public static void canonicalizeTransitions(
List<Transition> transitions,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (transitions == null || machineTypes == null) {
return;
}
for (Transition transition : transitions) {
if (transition.getEvent() != null) {
transition.setEvent(canonicalizeEvent(transition.getEvent(), machineTypes.eventTypeFqn()));
}
if (transition.getSourceStates() != null) {
transition.setSourceStates(canonicalizeStates(transition.getSourceStates(), machineTypes.stateTypeFqn()));
}
if (transition.getTargetStates() != null) {
transition.setTargetStates(canonicalizeStates(transition.getTargetStates(), machineTypes.stateTypeFqn()));
}
}
}
public static Set<String> canonicalizeStateLabels(Set<String> labels, String stateTypeFqn) {
if (labels == null || labels.isEmpty()) {
return labels;
}
return labels.stream()
.map(label -> canonicalizeLabel(label, stateTypeFqn))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
public static Set<State> canonicalizeStates(Set<State> states, String stateTypeFqn) {
if (states == null || states.isEmpty()) {
return states;
}
return states.stream()
.map(state -> canonicalizeState(state, stateTypeFqn))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
public static TriggerPoint canonicalizeTriggerPoint(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (trigger == null) {
return null;
}
String eventTypeFqn = preferFullTypeFqn(
trigger.getEventTypeFqn(),
machineTypes != null ? machineTypes.eventTypeFqn() : null);
String stateTypeFqn = preferFullTypeFqn(
trigger.getStateTypeFqn(),
machineTypes != null ? machineTypes.stateTypeFqn() : null);
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream()
.map(event -> canonicalizeLabel(event, eventTypeFqn))
.collect(Collectors.toList());
return trigger.toBuilder()
.eventTypeFqn(eventTypeFqn)
.stateTypeFqn(stateTypeFqn)
.event(canonicalizeLabel(trigger.getEvent(), eventTypeFqn))
.sourceState(canonicalizeLabel(trigger.getSourceState(), stateTypeFqn))
.polymorphicEvents(polymorphicEvents)
.build();
}
public static String qualifyEventIdentifier(String event, String eventTypeFqn) {
if (event == null || eventTypeFqn == null || event.isEmpty()) {
return event;
}
if (event.startsWith("<SYMBOLIC: ")) {
return event;
}
if (isWildcardVariable(event)) {
return event;
}
if (Character.isLowerCase(event.charAt(0))) {
return event;
}
if (!isEnumConstantCandidate(event)) {
return event;
}
if (isStringOrPrimitiveType(eventTypeFqn)) {
return event;
}
if (isMachineEnumReference(event, eventTypeFqn)) {
return canonicalizeLabel(event, stripGenerics(eventTypeFqn));
}
return event;
}
private static String preferFullTypeFqn(String primary, String fallback) {
if (primary != null && primary.contains(".")) {
return stripGenerics(primary);
}
if (fallback != null && fallback.contains(".")) {
return stripGenerics(fallback);
}
return primary != null ? primary : fallback;
}
private static String stripGenerics(String typeFqn) {
int idx = typeFqn.indexOf('<');
return idx >= 0 ? typeFqn.substring(0, idx) : typeFqn;
}
public static boolean isStringOrPrimitiveType(String typeFqn) {
if (typeFqn == null) {
return false;
}
return typeFqn.equals("String") || typeFqn.equals("java.lang.String")
|| typeFqn.equals("int") || typeFqn.equals("long") || typeFqn.equals("char")
|| typeFqn.equals("java.lang.Object") || typeFqn.equals("Object");
}
private static boolean isEnumConstantCandidate(String eventStr) {
if (eventStr == null) {
return false;
}
if (eventStr.startsWith("<SYMBOLIC: ")) {
return false;
}
return !eventStr.contains(" ") && !eventStr.contains("(") && !eventStr.contains(")")
&& !eventStr.contains("?");
}
private static boolean isWildcardVariable(String eventStr) {
if (eventStr == null) {
return false;
}
if (eventStr.equals("event") || eventStr.equals("e")
|| eventStr.equals("msg") || eventStr.equals("message")
|| eventStr.equals("payload")) {
return true;
}
if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) {
return true;
}
return eventStr.contains(" ? ") && eventStr.contains(" : ");
}
private static List<State> canonicalizeStates(List<State> states, String stateTypeFqn) {
if (states == null) {
return null;
}
List<State> canonical = new ArrayList<>(states.size());
for (State state : states) {
canonical.add(canonicalizeState(state, stateTypeFqn));
}
return canonical;
}
static Event canonicalizeEvent(Event event, String enumTypeFqn) {
if (event == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return event;
}
String canonical = canonicalizeLabel(
event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(),
enumTypeFqn);
if (canonical.equals(event.fullIdentifier())) {
return event;
}
return Event.of(event.rawName(), canonical);
}
static State canonicalizeState(State state, String enumTypeFqn) {
if (state == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return state;
}
String canonical = canonicalizeLabel(
state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName(),
enumTypeFqn);
if (canonical.equals(state.fullIdentifier())) {
return state;
}
return State.of(state.rawName(), canonical);
}
public static boolean isMachineEnumReference(String value, String enumTypeFqn) {
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return false;
}
if (isStringOrPrimitiveType(enumTypeFqn)) {
return false;
}
if (value.startsWith("<SYMBOLIC: ")) {
return false;
}
if (value.length() >= 2 && value.startsWith("\"")) {
return false;
}
if (!isEnumConstantCandidate(value)) {
return false;
}
if (Character.isLowerCase(value.charAt(0))) {
return false;
}
String typePart = enumTypeFromRef(value);
if (typePart == null) {
return true;
}
return enumTypesMatch(enumTypeFqn, typePart);
}
public static String canonicalizeLabel(String value, String enumTypeFqn) {
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return value;
}
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
return stripQuotes(value);
}
String stripped = value;
if (stripped == null || stripped.isBlank()) {
return value;
}
if (stripped.startsWith(enumTypeFqn + ".")) {
return stripped;
}
String constant = constantName(stripped);
String typePart = enumTypeFromRef(stripped);
if (typePart != null && enumTypesMatch(enumTypeFqn, typePart)) {
return enumTypeFqn + "." + constant;
}
if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) {
return enumTypeFqn + "." + stripped;
}
return stripped;
}
private static String stripQuotes(String value) {
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
return value.substring(1, value.length() - 1);
}
return value;
}
private static String constantName(String ref) {
int dot = ref.lastIndexOf('.');
return dot >= 0 ? ref.substring(dot + 1) : ref;
}
private static String enumTypeFromRef(String ref) {
int dot = ref.lastIndexOf('.');
if (dot <= 0) {
return null;
}
return ref.substring(0, dot);
}
static boolean enumTypesMatch(String type1, String type2) {
if (type1 == null || type2 == null) {
return false;
}
if (type1.equals(type2)) {
return true;
}
if (type1.endsWith("." + type2) || type2.endsWith("." + type1)) {
return true;
}
String simple1 = simpleName(type1);
String simple2 = simpleName(type2);
if (!simple1.equals(simple2)) {
return false;
}
return !type1.contains(".") || !type2.contains(".");
}
private static String simpleName(String fqn) {
return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
}
}

View File

@@ -0,0 +1,254 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Resolves {@code StateMachineConfigurerAdapter<S, E>} type arguments for a config class.
* Used by analysis (canonical enum ids) and routing (machine scoping).
*/
public final class StateMachineTypeResolver {
public record MachineTypes(String stateTypeFqn, String eventTypeFqn) {
public static MachineTypes empty() {
return new MachineTypes(null, null);
}
}
private StateMachineTypeResolver() {
}
public static String[] resolve(String machineConfigFqn, CodebaseContext context) {
MachineTypes types = resolveTypes(machineConfigFqn, context);
return new String[]{types.stateTypeFqn(), types.eventTypeFqn()};
}
public static MachineTypes resolveTypes(String machineConfigFqn, CodebaseContext context) {
if (machineConfigFqn == null || context == null) {
return MachineTypes.empty();
}
String configFqn = machineConfigFqn;
if (configFqn.contains("#")) {
configFqn = configFqn.substring(0, configFqn.indexOf('#'));
}
TypeDeclaration td = context.getTypeDeclaration(configFqn);
if (td == null) {
return MachineTypes.empty();
}
Set<String> visited = new HashSet<>();
visited.add(configFqn);
String[] args = findTypeArgumentsRecursively(
td.getSuperclassType(),
context,
(CompilationUnit) td.getRoot(),
visited,
Map.of());
return new MachineTypes(args[0], args[1]);
}
public static boolean extendsEnumStateMachineConfigurer(String machineConfigFqn, CodebaseContext context) {
if (machineConfigFqn == null || context == null) {
return false;
}
String configFqn = machineConfigFqn;
if (configFqn.contains("#")) {
configFqn = configFqn.substring(0, configFqn.indexOf('#'));
}
TypeDeclaration td = context.getTypeDeclaration(configFqn);
if (td == null) {
return false;
}
Set<String> visited = new HashSet<>();
visited.add(configFqn);
return hierarchyHasEnumConfigurer(
td.getSuperclassType(),
context,
(CompilationUnit) td.getRoot(),
visited);
}
private static boolean hierarchyHasEnumConfigurer(
Type type,
CodebaseContext context,
CompilationUnit cu,
Set<String> visited) {
if (type == null) {
return false;
}
if (type instanceof ParameterizedType pt) {
String rawTypeName = pt.getType().toString();
if (rawTypeName.equals("EnumStateMachineConfigurerAdapter")
|| rawTypeName.endsWith(".EnumStateMachineConfigurerAdapter")) {
return true;
}
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(rawTypeName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
Type superclass = td instanceof TypeDeclaration typeDecl ? typeDecl.getSuperclassType() : null;
if (hierarchyHasEnumConfigurer(superclass, context, cu, visited)) {
return true;
}
}
}
} else {
String simpleName = type.toString();
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
Type superclass = td instanceof TypeDeclaration typeDecl ? typeDecl.getSuperclassType() : null;
if (hierarchyHasEnumConfigurer(superclass, context, cu, visited)) {
return true;
}
}
}
}
return false;
}
private static String[] findTypeArgumentsRecursively(
Type type,
CodebaseContext context,
CompilationUnit cu,
Set<String> visited,
Map<String, String> typeVarBindings) {
if (type == null) {
return new String[]{null, null};
}
if (type instanceof ParameterizedType pt) {
String rawTypeName = pt.getType().toString();
List<?> typeArgs = pt.typeArguments();
if (isStateMachineConfigurerAdapter(rawTypeName)) {
if (typeArgs.size() >= 2) {
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), cu, context, typeVarBindings);
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), cu, context, typeVarBindings);
return new String[]{stateType, eventType};
}
}
Map<String, String> nextBindings = extendBindings(rawTypeName, typeArgs, cu, context, typeVarBindings);
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(rawTypeName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
Type superclass = td instanceof TypeDeclaration typeDecl ? typeDecl.getSuperclassType() : null;
String[] res = findTypeArgumentsRecursively(superclass, context, cu, visited, nextBindings);
if (res[0] != null || res[1] != null) {
return res;
}
}
}
} else {
String simpleName = type.toString();
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
Type superclass = td instanceof TypeDeclaration typeDecl ? typeDecl.getSuperclassType() : null;
String[] res = findTypeArgumentsRecursively(superclass, context, cu, visited, typeVarBindings);
if (res[0] != null || res[1] != null) {
return res;
}
}
}
}
return new String[]{null, null};
}
private static Map<String, String> extendBindings(
String rawTypeName,
List<?> typeArgs,
CompilationUnit cu,
CodebaseContext context,
Map<String, String> parentBindings) {
Map<String, String> bindings = new HashMap<>(parentBindings);
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(rawTypeName, cu);
if (!(td instanceof TypeDeclaration typeDecl) || typeArgs.isEmpty()) {
return bindings;
}
List<TypeParameter> typeParameters = typeDecl.typeParameters();
for (int i = 0; i < Math.min(typeParameters.size(), typeArgs.size()); i++) {
String paramName = typeParameters.get(i).getName().getIdentifier();
String resolvedArg = resolveTypeToFqn((Type) typeArgs.get(i), cu, context, parentBindings);
if (resolvedArg != null) {
bindings.put(paramName, resolvedArg);
}
}
return bindings;
}
private static boolean isStateMachineConfigurerAdapter(String rawTypeName) {
return rawTypeName.equals("StateMachineConfigurerAdapter")
|| rawTypeName.equals("EnumStateMachineConfigurerAdapter")
|| rawTypeName.endsWith(".StateMachineConfigurerAdapter")
|| rawTypeName.endsWith(".EnumStateMachineConfigurerAdapter");
}
static String resolveTypeToFqn(Type type, CompilationUnit cu, CodebaseContext context) {
return resolveTypeToFqn(type, cu, context, Map.of());
}
static String resolveTypeToFqn(
Type type,
CompilationUnit cu,
CodebaseContext context,
Map<String, String> typeVarBindings) {
if (type == null) {
return null;
}
if (type instanceof WildcardType wildcardType) {
Type bound = wildcardType.getBound();
return bound != null ? resolveTypeToFqn(bound, cu, context, typeVarBindings) : null;
}
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), cu, context, typeVarBindings);
} else {
simpleName = type.toString();
}
if (simpleName.contains("<")) {
simpleName = simpleName.substring(0, simpleName.indexOf('<'));
}
if (typeVarBindings.containsKey(simpleName)) {
return typeVarBindings.get(simpleName);
}
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) {
return context.getFqn(localTd);
}
}
return simpleName;
}
}

View File

@@ -0,0 +1,107 @@
package click.kamil.springstatemachineexporter.analysis.service;
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.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.validation.AnalysisCanonicalFormValidator;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.List;
import java.util.stream.Collectors;
/**
* Re-applies machine-scoped enum canonicalization and validates the analysis model.
* Must run after property resolution so placeholders cannot bypass validation.
*/
public final class AnalysisResultFinalizer {
private AnalysisResultFinalizer() {
}
public static void finalizeResult(AnalysisResult result, CodebaseContext context) {
if (result == null || context == null) {
return;
}
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
MachineEnumCanonicalizer.canonicalizeTransitions(result.getTransitions(), machineTypes);
if (result.getStates() != null) {
result.setStates(MachineEnumCanonicalizer.canonicalizeStates(result.getStates(), machineTypes.stateTypeFqn()));
}
result.setStartStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
result.getStartStates(), machineTypes.stateTypeFqn()));
result.setEndStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
result.getEndStates(), machineTypes.stateTypeFqn()));
if (result.getMetadata() != null) {
CodebaseMetadata metadata = result.getMetadata();
List<TriggerPoint> triggers = canonicalizeTriggers(metadata.getTriggers(), machineTypes);
List<CallChain> callChains = canonicalizeCallChains(metadata.getCallChains(), machineTypes);
result.setMetadata(CodebaseMetadata.builder()
.triggers(triggers)
.entryPoints(metadata.getEntryPoints())
.callChains(callChains)
.properties(metadata.getProperties())
.build());
}
AnalysisCanonicalFormValidator.enforce(result, context);
}
private static List<TriggerPoint> canonicalizeTriggers(
List<TriggerPoint> triggers,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (triggers == null) {
return null;
}
return triggers.stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
.collect(Collectors.toList());
}
private static List<CallChain> canonicalizeCallChains(
List<CallChain> callChains,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (callChains == null) {
return null;
}
return callChains.stream()
.map(chain -> {
TriggerPoint trigger = chain.getTriggerPoint() == null
? null
: MachineEnumCanonicalizer.canonicalizeTriggerPoint(chain.getTriggerPoint(), machineTypes);
List<MatchedTransition> matched = canonicalizeMatchedTransitions(
chain.getMatchedTransitions(), machineTypes);
return chain.toBuilder()
.triggerPoint(trigger)
.matchedTransitions(matched)
.build();
})
.collect(Collectors.toList());
}
private static List<MatchedTransition> canonicalizeMatchedTransitions(
List<MatchedTransition> matchedTransitions,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (matchedTransitions == null) {
return null;
}
return matchedTransitions.stream()
.map(matched -> MatchedTransition.builder()
.event(MachineEnumCanonicalizer.canonicalizeLabel(
matched.getEvent(), machineTypes.eventTypeFqn()))
.sourceState(MachineEnumCanonicalizer.canonicalizeLabel(
matched.getSourceState(), machineTypes.stateTypeFqn()))
.targetState(MachineEnumCanonicalizer.canonicalizeLabel(
matched.getTargetState(), machineTypes.stateTypeFqn()))
.build())
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,32 @@
package click.kamil.springstatemachineexporter.analysis.validation;
import java.util.ArrayList;
import java.util.List;
/**
* Thrown when {@link AnalysisCanonicalFormValidator} finds enum identifiers that are not
* package-canonical for the machine's resolved {@code <State, Event>} types.
*/
public class AnalysisCanonicalFormException extends IllegalStateException {
private final List<AnalysisCanonicalFormValidator.Violation> violations;
public AnalysisCanonicalFormException(List<AnalysisCanonicalFormValidator.Violation> violations) {
super(formatMessage(violations));
this.violations = List.copyOf(violations);
}
public List<AnalysisCanonicalFormValidator.Violation> getViolations() {
return violations;
}
private static String formatMessage(List<AnalysisCanonicalFormValidator.Violation> violations) {
StringBuilder sb = new StringBuilder("Analysis model contains non-canonical enum identifiers:");
for (AnalysisCanonicalFormValidator.Violation violation : violations) {
sb.append("\n - ").append(violation.path())
.append(": '").append(violation.actual())
.append("' (expected '").append(violation.expected()).append("')");
}
return sb.toString();
}
}

View File

@@ -0,0 +1,230 @@
package click.kamil.springstatemachineexporter.analysis.validation;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
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;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Validates that enum-backed identifiers across the analysis model share one canonical shape:
* {@code {package.EnumType.CONSTANT}} derived from the machine config's {@code <State, Event>} types.
*
* <p>String/primitive machines ({@code StateMachineConfigurerAdapter<String, String>}) are skipped.
*/
public final class AnalysisCanonicalFormValidator {
public record Violation(String path, String actual, String expected) {
}
private AnalysisCanonicalFormValidator() {
}
public static List<Violation> validate(AnalysisResult result, CodebaseContext context) {
List<Violation> violations = new ArrayList<>();
if (result == null || context == null) {
return violations;
}
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
if (!hasPackageQualifiedEnumType(machineTypes.stateTypeFqn())
&& !hasPackageQualifiedEnumType(machineTypes.eventTypeFqn())) {
if (!StateMachineTypeResolver.extendsEnumStateMachineConfigurer(result.getName(), context)) {
return violations;
}
violations.add(new Violation(
"machineTypes",
describeTypes(machineTypes),
"package-qualified state/event enum types"));
return violations;
}
validateTransitions(result.getTransitions(), machineTypes, violations);
validateStateCollection(result.getStates(), machineTypes.stateTypeFqn(), "states", violations);
validateStateLabels(result.getStartStates(), machineTypes.stateTypeFqn(), "startStates", violations);
validateStateLabels(result.getEndStates(), machineTypes.stateTypeFqn(), "endStates", violations);
if (result.getMetadata() != null) {
validateTriggers(result.getMetadata().getTriggers(), machineTypes, violations);
validateCallChains(result.getMetadata().getCallChains(), machineTypes, violations);
}
return violations;
}
public static void enforce(AnalysisResult result, CodebaseContext context) {
List<Violation> violations = validate(result, context);
if (!violations.isEmpty()) {
throw new AnalysisCanonicalFormException(violations);
}
}
private static String describeTypes(StateMachineTypeResolver.MachineTypes machineTypes) {
return "stateTypeFqn=" + machineTypes.stateTypeFqn() + ", eventTypeFqn=" + machineTypes.eventTypeFqn();
}
private static boolean hasPackageQualifiedEnumType(String typeFqn) {
return typeFqn != null && typeFqn.contains(".") && !MachineEnumCanonicalizer.isStringOrPrimitiveType(typeFqn);
}
private static void validateTransitions(
List<Transition> transitions,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) {
if (transitions == null) {
return;
}
for (int i = 0; i < transitions.size(); i++) {
Transition transition = transitions.get(i);
String prefix = "transitions[" + i + "]";
if (transition.getEvent() != null) {
requireCanonical(
prefix + ".event.fullIdentifier",
transition.getEvent().fullIdentifier(),
machineTypes.eventTypeFqn(),
violations);
}
validateStateList(transition.getSourceStates(), machineTypes.stateTypeFqn(), prefix + ".sourceStates", violations);
validateStateList(transition.getTargetStates(), machineTypes.stateTypeFqn(), prefix + ".targetStates", violations);
}
}
private static void validateStateList(
List<State> states,
String stateTypeFqn,
String pathPrefix,
List<Violation> violations) {
if (states == null) {
return;
}
for (int i = 0; i < states.size(); i++) {
requireCanonical(pathPrefix + "[" + i + "].fullIdentifier", states.get(i).fullIdentifier(), stateTypeFqn, violations);
}
}
private static void validateStateCollection(
Set<State> states,
String stateTypeFqn,
String pathPrefix,
List<Violation> violations) {
if (states == null) {
return;
}
int i = 0;
for (State state : states) {
requireCanonical(pathPrefix + "[" + i++ + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations);
}
}
private static void validateStateLabels(
Set<String> labels,
String stateTypeFqn,
String pathPrefix,
List<Violation> violations) {
if (labels == null) {
return;
}
int i = 0;
for (String label : labels) {
requireCanonical(pathPrefix + "[" + i++ + "]", label, stateTypeFqn, violations);
}
}
private static void validateTriggers(
List<TriggerPoint> triggers,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) {
if (triggers == null) {
return;
}
for (int i = 0; i < triggers.size(); i++) {
TriggerPoint trigger = triggers.get(i);
String prefix = "metadata.triggers[" + i + "]";
String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn());
String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn());
requireCanonical(prefix + ".event", trigger.getEvent(), eventTypeFqn, violations);
requireCanonical(prefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations);
if (trigger.getPolymorphicEvents() != null) {
for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) {
requireCanonical(
prefix + ".polymorphicEvents[" + j + "]",
trigger.getPolymorphicEvents().get(j),
eventTypeFqn,
violations);
}
}
}
}
private static void validateCallChains(
List<CallChain> callChains,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) {
if (callChains == null) {
return;
}
for (int i = 0; i < callChains.size(); i++) {
CallChain chain = callChains.get(i);
String prefix = "metadata.callChains[" + i + "]";
TriggerPoint trigger = chain.getTriggerPoint();
if (trigger != null) {
String triggerPrefix = prefix + ".triggerPoint";
String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn());
String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn());
requireCanonical(triggerPrefix + ".event", trigger.getEvent(), eventTypeFqn, violations);
requireCanonical(triggerPrefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations);
if (trigger.getPolymorphicEvents() != null) {
for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) {
requireCanonical(
triggerPrefix + ".polymorphicEvents[" + j + "]",
trigger.getPolymorphicEvents().get(j),
eventTypeFqn,
violations);
}
}
}
if (chain.getMatchedTransitions() != null) {
for (int j = 0; j < chain.getMatchedTransitions().size(); j++) {
MatchedTransition matched = chain.getMatchedTransitions().get(j);
String matchedPrefix = prefix + ".matchedTransitions[" + j + "]";
requireCanonical(matchedPrefix + ".event", matched.getEvent(), machineTypes.eventTypeFqn(), violations);
requireCanonical(matchedPrefix + ".sourceState", matched.getSourceState(), machineTypes.stateTypeFqn(), violations);
requireCanonical(matchedPrefix + ".targetState", matched.getTargetState(), machineTypes.stateTypeFqn(), violations);
}
}
}
}
private static String preferTypeFqn(String primary, String fallback) {
if (hasPackageQualifiedEnumType(primary)) {
return primary;
}
return fallback;
}
private static void requireCanonical(
String path,
String value,
String enumTypeFqn,
List<Violation> violations) {
if (!MachineEnumCanonicalizer.isMachineEnumReference(value, enumTypeFqn)) {
return;
}
String expected = MachineEnumCanonicalizer.canonicalizeLabel(value, enumTypeFqn);
if (!expected.equals(value)) {
violations.add(new Violation(path, value, expected));
}
}
}

View File

@@ -1,11 +1,14 @@
package click.kamil.springstatemachineexporter.service;
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
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.service.AnalysisResultFinalizer;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider;
@@ -47,6 +50,7 @@ public class ExportService {
new PropertyEnricher(),
new CallChainEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.path.ForwardPathEstimationEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher()
)));
}
@@ -233,16 +237,23 @@ public class ExportService {
StateMachineAggregator aggregator = new StateMachineAggregator(context);
List<Transition> transitions = aggregator.aggregateTransitions(td);
StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.resolveTypes(className, context);
MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes);
aggregator.aggregateStates(td);
Set<String> initialStatesAst = aggregator.getInitialStates();
Set<String> endStatesAst = aggregator.getEndStates();
Set<String> initialStatesAst = MachineEnumCanonicalizer.canonicalizeStateLabels(
aggregator.getInitialStates(), machineTypes.stateTypeFqn());
Set<String> endStatesAst = MachineEnumCanonicalizer.canonicalizeStateLabels(
aggregator.getEndStates(), machineTypes.stateTypeFqn());
log.debug("Start States Ast: {}", initialStatesAst);
log.debug("End States Ast: {}", endStatesAst);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions, initialStatesAst);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst);
Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst);
Set<click.kamil.springstatemachineexporter.model.State> allStates = MachineEnumCanonicalizer.canonicalizeStates(
TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst),
machineTypes.stateTypeFqn());
if (allStates.isEmpty() && transitions.isEmpty()) {
log.info("Skipping empty state machine config: {}", className);
@@ -262,6 +273,7 @@ public class ExportService {
enrichmentService.enrich(result, context, intelligence);
resolveProperties(result, activeProfiles);
AnalysisResultFinalizer.finalizeResult(result, context);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
}
@@ -276,9 +288,14 @@ public class ExportService {
List<Transition> transitions = AstTransitionParser.parseTransitions(m, context);
StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.resolveTypes(parentFqn, context);
MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions, null);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, null);
Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, null, null);
Set<click.kamil.springstatemachineexporter.model.State> allStates = MachineEnumCanonicalizer.canonicalizeStates(
TransitionStateUtils.findAllStates(transitions, null, null),
machineTypes.stateTypeFqn());
if (allStates.isEmpty() && transitions.isEmpty()) {
log.info("Skipping empty state machine bean: {}", uniqueName);
@@ -298,6 +315,7 @@ public class ExportService {
enrichmentService.enrich(result, context, intelligence);
resolveProperties(result, activeProfiles);
AnalysisResultFinalizer.finalizeResult(result, context);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
}