Defer trigger event qualification and resolve types from bindings

Stop rewriting trigger events in TriggerPoint construction; qualify with context during property resolution and export. Upgrade heuristic simple names to JDT binding FQNs for user types only, preserving java.* platform types.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 06:24:40 +02:00
parent 57686c575e
commit 8c4f15133e
8 changed files with 140 additions and 21 deletions

View File

@@ -9,6 +9,7 @@ import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition; import click.kamil.springstatemachineexporter.model.Transition;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@@ -46,6 +47,10 @@ public class AnalysisResult {
private CodebaseMetadata metadata = CodebaseMetadata.empty(); private CodebaseMetadata metadata = CodebaseMetadata.empty();
public void applyResolution(Map<String, String> properties) { public void applyResolution(Map<String, String> properties) {
applyResolution(properties, null);
}
public void applyResolution(Map<String, String> properties, CodebaseContext context) {
var resolver = new click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver(); var resolver = new click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver();
// 1. Resolve start/end states strings // 1. Resolve start/end states strings
@@ -89,12 +94,12 @@ public class AnalysisResult {
if (metadata != null) { if (metadata != null) {
List<TriggerPoint> resolvedTriggers = metadata.getTriggers() == null ? null List<TriggerPoint> resolvedTriggers = metadata.getTriggers() == null ? null
: metadata.getTriggers().stream() : metadata.getTriggers().stream()
.map(trigger -> resolveTrigger(trigger, resolver, properties)) .map(trigger -> resolveTrigger(trigger, resolver, properties, context))
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
List<CallChain> resolvedCallChains = metadata.getCallChains() == null ? null List<CallChain> resolvedCallChains = metadata.getCallChains() == null ? null
: metadata.getCallChains().stream() : metadata.getCallChains().stream()
.map(chain -> resolveCallChain(chain, resolver, properties)) .map(chain -> resolveCallChain(chain, resolver, properties, context))
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
if (metadata.getEntryPoints() != null) { if (metadata.getEntryPoints() != null) {
@@ -136,17 +141,26 @@ public class AnalysisResult {
private static TriggerPoint resolveTrigger( private static TriggerPoint resolveTrigger(
TriggerPoint trigger, TriggerPoint trigger,
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver, click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
Map<String, String> properties) { Map<String, String> properties,
CodebaseContext context) {
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream() : trigger.getPolymorphicEvents().stream()
.map(value -> resolver.resolveValue(value, properties)) .map(value -> resolver.resolveValue(value, properties))
.map(value -> MachineEnumCanonicalizer.qualifyEventIdentifier( .map(value -> MachineEnumCanonicalizer.qualifyEventIdentifier(
value, trigger.getEventTypeFqn())) value, trigger.getEventTypeFqn(), context))
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
String resolvedEvent = trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null;
String resolvedSource = trigger.getSourceState() != null
? resolver.resolveValue(trigger.getSourceState(), properties)
: null;
return trigger.toBuilder() return trigger.toBuilder()
.event(trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null) .event(resolvedEvent != null
.sourceState(trigger.getSourceState() != null ? MachineEnumCanonicalizer.qualifyEventIdentifier(
? resolver.resolveValue(trigger.getSourceState(), properties) resolvedEvent, trigger.getEventTypeFqn(), context)
: null)
.sourceState(resolvedSource != null
? MachineEnumCanonicalizer.canonicalizeLabel(
resolvedSource, trigger.getStateTypeFqn(), context)
: null) : null)
.polymorphicEvents(polymorphicEvents) .polymorphicEvents(polymorphicEvents)
.build(); .build();
@@ -155,10 +169,11 @@ public class AnalysisResult {
private static CallChain resolveCallChain( private static CallChain resolveCallChain(
CallChain chain, CallChain chain,
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver, click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
Map<String, String> properties) { Map<String, String> properties,
CodebaseContext context) {
TriggerPoint trigger = chain.getTriggerPoint() == null TriggerPoint trigger = chain.getTriggerPoint() == null
? null ? null
: resolveTrigger(chain.getTriggerPoint(), resolver, properties); : resolveTrigger(chain.getTriggerPoint(), resolver, properties, context);
List<MatchedTransition> matchedTransitions = chain.getMatchedTransitions() == null ? null List<MatchedTransition> matchedTransitions = chain.getMatchedTransitions() == null ? null
: chain.getMatchedTransitions().stream() : chain.getMatchedTransitions().stream()
.map(matched -> MatchedTransition.builder() .map(matched -> MatchedTransition.builder()

View File

@@ -50,11 +50,11 @@ public class TriggerPoint {
this.sourceFile = sourceFile; this.sourceFile = sourceFile;
this.sourceModule = sourceModule; this.sourceModule = sourceModule;
this.stateMachineId = stateMachineId; this.stateMachineId = stateMachineId;
this.sourceState = MachineEnumCanonicalizer.canonicalizeLabel(sourceState, stateTypeFqn); this.sourceState = sourceState;
this.lineNumber = lineNumber; this.lineNumber = lineNumber;
this.stateTypeFqn = stateTypeFqn; this.stateTypeFqn = stateTypeFqn;
this.eventTypeFqn = eventTypeFqn; this.eventTypeFqn = eventTypeFqn;
this.event = MachineEnumCanonicalizer.qualifyEventIdentifier(event, eventTypeFqn); this.event = event;
this.polymorphicEvents = polymorphicEvents; this.polymorphicEvents = polymorphicEvents;
this.external = external; this.external = external;
this.constraint = constraint; this.constraint = constraint;

View File

@@ -533,6 +533,10 @@ public final class MachineEnumCanonicalizer {
} }
public static String qualifyEventIdentifier(String event, String eventTypeFqn) { public static String qualifyEventIdentifier(String event, String eventTypeFqn) {
return qualifyEventIdentifier(event, eventTypeFqn, null);
}
public static String qualifyEventIdentifier(String event, String eventTypeFqn, CodebaseContext context) {
if (event == null || eventTypeFqn == null || event.isEmpty()) { if (event == null || eventTypeFqn == null || event.isEmpty()) {
return event; return event;
} }
@@ -554,8 +558,8 @@ public final class MachineEnumCanonicalizer {
if (isStringOrPrimitiveType(eventTypeFqn)) { if (isStringOrPrimitiveType(eventTypeFqn)) {
return event; return event;
} }
if (isMachineEnumReference(event, eventTypeFqn)) { if (isMachineEnumReference(event, eventTypeFqn, context)) {
return canonicalizeLabel(event, stripGenerics(eventTypeFqn)); return canonicalizeLabel(event, stripGenerics(eventTypeFqn), context);
} }
return event; return event;
} }
@@ -742,6 +746,10 @@ public final class MachineEnumCanonicalizer {
} }
public static boolean isMachineEnumReference(String value, String enumTypeFqn) { public static boolean isMachineEnumReference(String value, String enumTypeFqn) {
return isMachineEnumReference(value, enumTypeFqn, null);
}
public static boolean isMachineEnumReference(String value, String enumTypeFqn, CodebaseContext context) {
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) { if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return false; return false;
} }
@@ -765,7 +773,10 @@ public final class MachineEnumCanonicalizer {
if (typePart == null) { if (typePart == null) {
return true; return true;
} }
return enumTypesMatch(enumTypeFqn, typePart); if (context != null && context.isAmbiguousSimpleName(typePart)) {
return false;
}
return enumTypesMatch(enumTypeFqn, typePart, context);
} }
public static String canonicalizeLabel(String value, String enumTypeFqn) { public static String canonicalizeLabel(String value, String enumTypeFqn) {

View File

@@ -79,7 +79,25 @@ public class TypeResolver {
} }
} }
return simpleName; return preferBindingFqn(type, simpleName);
}
private String preferBindingFqn(Type type, String heuristic) {
if (!context.isResolveBindings() || heuristic == null || heuristic.contains(".")) {
return heuristic;
}
ITypeBinding binding = type.resolveBinding();
if (binding == null || binding.isRecovered()) {
return heuristic;
}
String bindingFqn = binding.getErasure().getQualifiedName();
if (bindingFqn == null || bindingFqn.isBlank() || !bindingFqn.contains(".") || bindingFqn.startsWith("<")) {
return heuristic;
}
if (bindingFqn.startsWith("java.") || bindingFqn.startsWith("javax.") || bindingFqn.startsWith("jakarta.")) {
return heuristic;
}
return bindingFqn;
} }
public int getParameterIndex(String methodFqn, String paramName) { public int getParameterIndex(String methodFqn, String paramName) {

View File

@@ -167,7 +167,7 @@ public class ExportService {
JsonImportService jsonImportService = new JsonImportService(); JsonImportService jsonImportService = new JsonImportService();
AnalysisResult result = jsonImportService.importAnalysisResult(jsonFile); AnalysisResult result = jsonImportService.importAnalysisResult(jsonFile);
resolveProperties(result, activeProfiles); resolveProperties(result, activeProfiles, null);
CodebaseContext context = null; CodebaseContext context = null;
Path sourceRoot = optionalSourceDir; Path sourceRoot = optionalSourceDir;
@@ -215,7 +215,7 @@ public class ExportService {
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat); generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
} }
private void resolveProperties(AnalysisResult result, List<String> activeProfiles) { private void resolveProperties(AnalysisResult result, List<String> activeProfiles, CodebaseContext context) {
Map<String, Map<String, String>> allProps = result.getMetadata().getProperties(); Map<String, Map<String, String>> allProps = result.getMetadata().getProperties();
if (allProps == null || allProps.isEmpty()) return; if (allProps == null || allProps.isEmpty()) return;
@@ -233,7 +233,7 @@ public class ExportService {
} }
// 2. Delegate to result for resolution // 2. Delegate to result for resolution
result.applyResolution(merged); result.applyResolution(merged, context);
} }
private void exportAll(CodebaseContext context, CodebaseIntelligenceProvider intelligence, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows, String machineFilter, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException { private void exportAll(CodebaseContext context, CodebaseIntelligenceProvider intelligence, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows, String machineFilter, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
@@ -305,7 +305,7 @@ public class ExportService {
.build(); .build();
enrichmentService.enrichPreProperty(result, context, intelligence); enrichmentService.enrichPreProperty(result, context, intelligence);
resolveProperties(result, activeProfiles); resolveProperties(result, activeProfiles, context);
enrichmentService.enrichPostProperty(result, context, intelligence); enrichmentService.enrichPostProperty(result, context, intelligence);
AnalysisResultFinalizer.finalizeResult(result, context); AnalysisResultFinalizer.finalizeResult(result, context);
@@ -347,7 +347,7 @@ public class ExportService {
.build(); .build();
enrichmentService.enrichPreProperty(result, context, intelligence); enrichmentService.enrichPreProperty(result, context, intelligence);
resolveProperties(result, activeProfiles); resolveProperties(result, activeProfiles, context);
enrichmentService.enrichPostProperty(result, context, intelligence); enrichmentService.enrichPostProperty(result, context, intelligence);
AnalysisResultFinalizer.finalizeResult(result, context); AnalysisResultFinalizer.finalizeResult(result, context);

View File

@@ -92,4 +92,22 @@ class MachineEnumCanonicalizerCrossPackageTest {
"com.example.order.OrderEvent.PAY", "com.example.order.OrderEvent.PAY",
"com.example.order.OrderEvent.SHIP"); "com.example.order.OrderEvent.SHIP");
} }
@Test
void shouldNotQualifyAmbiguousImportStyleEventReference(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
"package a; public enum OrderEvent { PAY }");
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { CANCEL }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(MachineEnumCanonicalizer.qualifyEventIdentifier(
"OrderEvent.PAY", "a.OrderEvent", context)).isEqualTo("OrderEvent.PAY");
assertThat(MachineEnumCanonicalizer.isMachineEnumReference(
"OrderEvent.PAY", "a.OrderEvent", context)).isFalse();
}
} }

View File

@@ -110,9 +110,12 @@ class AnalysisResultFinalizerTest {
.metadata(CodebaseMetadata.builder().triggers(List.of(trigger)).build()) .metadata(CodebaseMetadata.builder().triggers(List.of(trigger)).build())
.build(); .build();
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
result.applyResolution(Map.of( result.applyResolution(Map.of(
"app.event", "OrderEvent.PAY", "app.event", "OrderEvent.PAY",
"app.state", "OrderState.NEW")); "app.state", "OrderState.NEW"), context);
TriggerPoint resolved = result.getMetadata().getTriggers().get(0); TriggerPoint resolved = result.getMetadata().getTriggers().get(0);
assertThat(resolved.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY"); assertThat(resolved.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY");

View File

@@ -1,6 +1,10 @@
package click.kamil.springstatemachineexporter.analysis.service; package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
@@ -48,4 +52,54 @@ class TypeResolverTest {
assertThat(resolver.isTypeCompatible("a.OrderEvent", "b.OrderEvent")).isFalse(); assertThat(resolver.isTypeCompatible("a.OrderEvent", "b.OrderEvent")).isFalse();
assertThat(resolver.isTypeCompatible("a.OrderEvent", "a.OrderEvent")).isTrue(); assertThat(resolver.isTypeCompatible("a.OrderEvent", "a.OrderEvent")).isTrue();
} }
@Test
void shouldUpgradeSimpleHeuristicTypeToBindingFqnForUserTypes(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("com/example/order"));
Files.writeString(tempDir.resolve("com/example/order/OrderEvent.java"), """
package com.example.order;
public enum OrderEvent { PAY }
""");
Files.writeString(tempDir.resolve("App.java"), """
package com.example;
import com.example.order.OrderEvent;
class StateMachine {
void sendEvent(OrderEvent e) {}
}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
TypeResolver resolver = new TypeResolver(context);
TypeDeclaration td = context.getTypeDeclaration("com.example.StateMachine");
MethodDeclaration md = context.findMethodDeclaration(td, "sendEvent", true);
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(0);
assertThat(resolver.resolveTypeToFqn(param.getType(), (CompilationUnit) td.getRoot()))
.isEqualTo("com.example.order.OrderEvent");
}
@Test
void shouldKeepHeuristicStringTypeWhenBindingsWouldReturnJavaLangString(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("App.java"), """
package com.example;
class Api {
void handle(String value) {}
}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
TypeResolver resolver = new TypeResolver(context);
TypeDeclaration td = context.getTypeDeclaration("com.example.Api");
MethodDeclaration md = context.findMethodDeclaration(td, "handle", true);
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(0);
assertThat(resolver.resolveTypeToFqn(param.getType(), (CompilationUnit) td.getRoot()))
.isEqualTo("String");
}
} }