From 5d3333d494108519ce492436b9d00301e99bd782 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Sun, 12 Jul 2026 09:43:50 +0200 Subject: [PATCH] Fix export metadata scoping and call-chain resolution gaps. Correct machine-scope filtering for domain paths and Spring type erasure, skip phantom REST interfaces, resolve custom message wrapper events, scope JDT call-chain lookup per machine, and wire scan-time analysis indexes with JDT/heuristic parity tests. Co-authored-by: Cursor --- .../analysis/enricher/MachineScopeFilter.java | 12 +- .../HeuristicBeanResolutionEngine.java | 18 +- .../analysis/index/FieldTypeIndex.java | 47 ++ .../analysis/index/FieldTypeIndexBuilder.java | 98 ++++ .../analysis/index/GetterChainEdge.java | 14 + .../analysis/index/GetterChainIndex.java | 47 ++ .../index/GetterChainIndexBuilder.java | 77 +++ .../index/IndexedFieldConstantsIndex.java | 54 ++ .../IndexedFieldConstantsIndexBuilder.java | 41 ++ .../index/PolymorphicAccessorEntry.java | 16 + .../index/PolymorphicAccessorIndex.java | 62 ++ .../PolymorphicAccessorIndexBuilder.java | 156 +++++ .../PolymorphicMethodExpansionIndex.java | 58 ++ ...olymorphicMethodExpansionIndexBuilder.java | 81 +++ .../index/ScanTimeAccessorConstants.java | 162 +++++ .../analysis/pipeline/AccessorResolver.java | 39 +- .../pipeline/ClassCompatibilityCache.java | 40 ++ .../service/AbstractCallGraphEngine.java | 553 ++++++++++++++++-- .../analysis/service/CallGraphBuilder.java | 25 +- .../analysis/service/CallGraphPathFinder.java | 21 +- .../analysis/service/ConstantExtractor.java | 59 +- .../analysis/service/ConstructorAnalyzer.java | 22 +- .../service/HeuristicCallGraphEngine.java | 371 ------------ .../analysis/service/JdtCallGraphEngine.java | 96 +-- .../service/JdtIntelligenceProvider.java | 15 +- .../analysis/service/SpringMvcDetector.java | 2 +- .../analysis/service/VariableTracer.java | 38 +- .../ast/common/CodebaseContext.java | 184 +++++- .../ast/common/JdtDataFlowModel.java | 11 + .../MachineScopeFilterEntryPointTest.java | 40 ++ .../HeuristicBeanResolutionEngineTest.java | 35 ++ .../index/AnalysisPerformanceCacheTest.java | 108 ++++ .../analysis/index/GetterChainIndexTest.java | 97 +++ .../index/PolymorphicAccessorIndexTest.java | 238 ++++++++ .../service/AnalysisCacheCorrectnessTest.java | 2 +- .../CentralDispatcherResolutionTest.java | 102 +++- .../service/CentralDispatcherTestSupport.java | 21 +- .../CustomMessageTriggerResolutionTest.java | 63 ++ .../service/InheritanceSampleChainTest.java | 60 ++ .../JdtCentralDispatcherParityTest.java | 148 +++++ .../service/SpringMvcDetectorTest.java | 36 ++ .../DocumentStateMachineConfiguration.json | 114 ---- .../EnterpriseStateMachineConfig.json | 323 +++++++++- .../ExtendedStateMachineConfig.json | 34 -- .../ExtendedStateMachineConfig.json | 34 -- .../InheritanceStateMachineConfig.json | 64 +- .../MavenOrderStateMachine.json | 57 +- ...mil.maven.core.MavenOrderStateMachine.json | 16 +- .../OrderStateMachineConfig.json | 66 ++- .../OrderStateMachineConfiguration.json | 76 --- ...dardDocumentStateMachineConfiguration.json | 33 ++ ...tandardOrderStateMachineConfiguration.json | 77 +++ .../StateMachineConfig.json | 52 +- .../UserStateMachineConfiguration.json | 72 --- 54 files changed, 3479 insertions(+), 908 deletions(-) create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/FieldTypeIndex.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/FieldTypeIndexBuilder.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainEdge.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainIndex.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainIndexBuilder.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/IndexedFieldConstantsIndex.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/IndexedFieldConstantsIndexBuilder.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorEntry.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorIndex.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorIndexBuilder.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicMethodExpansionIndex.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicMethodExpansionIndexBuilder.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/ScanTimeAccessorConstants.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ClassCompatibilityCache.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AnalysisPerformanceCacheTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainIndexTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorIndexTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CustomMessageTriggerResolutionTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/InheritanceSampleChainTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtCentralDispatcherParityTest.java diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilter.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilter.java index fe891b3..1be184f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilter.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilter.java @@ -72,7 +72,12 @@ public final class MachineScopeFilter { String path = entryPoint.getMetadata() != null ? entryPoint.getMetadata().get("path") : null; if (path != null) { String pathDomain = inferDomainFromPath(path); - if (pathDomain != null && machineDomain != null && !domainsMatch(pathDomain, machineDomain)) { + if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain) + && domainsMatch(pathDomain, machineDomain)) { + return true; + } + if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain) + && !domainsMatch(pathDomain, machineDomain)) { return false; } if (pathDomain == null && path.contains("{")) { @@ -86,6 +91,11 @@ public final class MachineScopeFilter { return ROUTING.isRoutedToCorrectMachine(probe, machineName, context); } + private static boolean isKnownDomainScopedMachine(String machineDomain) { + String normalized = normalizeDomainKey(machineDomain); + return "ORDER".equals(normalized) || "DOCUMENT".equals(normalized) || "USER".equals(normalized); + } + private static String inferDomainFromPath(String path) { String lower = path.toLowerCase(Locale.ROOT); if (lower.contains("/orders") || lower.contains("order.")) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java index 75bcae9..e0b4848 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java @@ -28,7 +28,10 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { if (isTypeMismatched(triggerEventFqn, machineEventFqn)) { return false; } - return false; + if (context != null && isSingleStateMachine(context) + && (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) { + return true; + } } if (triggerStateFqn != null && machineStateFqn != null) { if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) { @@ -37,6 +40,10 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { if (isTypeMismatched(triggerStateFqn, machineStateFqn)) { return false; // Mismatch! } + if (context != null && isSingleStateMachine(context) + && (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) { + return true; + } } } } @@ -319,6 +326,15 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { return !type1.equals(type2); } + private boolean isErasedOrOpaqueType(String typeFqn) { + if (typeFqn == null) { + return false; + } + String erased = eraseGenerics(typeFqn); + return "java.lang.Object".equals(erased) || "Object".equals(erased) + || "java.io.Serializable".equals(erased) || "Serializable".equals(erased); + } + private boolean isSingleStateMachine(CodebaseContext context) { return countStateMachines(context) <= 1; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/FieldTypeIndex.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/FieldTypeIndex.java new file mode 100644 index 0000000..a85c874 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/FieldTypeIndex.java @@ -0,0 +1,47 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public final class FieldTypeIndex { + + private static final FieldTypeIndex EMPTY = new FieldTypeIndex(Map.of(), 0); + + private final Map> byOwnerAndField; + private final int fieldCount; + + public FieldTypeIndex(Map> byOwnerAndField, int fieldCount) { + this.byOwnerAndField = Collections.unmodifiableMap(deepCopy(byOwnerAndField)); + this.fieldCount = fieldCount; + } + + public static FieldTypeIndex empty() { + return EMPTY; + } + + public Optional lookup(String ownerFqn, String fieldName) { + if (ownerFqn == null || fieldName == null) { + return Optional.empty(); + } + Map fields = byOwnerAndField.get(ownerFqn); + if (fields == null) { + return Optional.empty(); + } + return Optional.ofNullable(fields.get(fieldName)); + } + + public int fieldCount() { + return fieldCount; + } + + private static Map> deepCopy(Map> source) { + Map> copy = new HashMap<>(); + for (Map.Entry> entry : source.entrySet()) { + copy.put(entry.getKey(), Map.copyOf(entry.getValue())); + } + return copy; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/FieldTypeIndexBuilder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/FieldTypeIndexBuilder.java new file mode 100644 index 0000000..8eb8b4c --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/FieldTypeIndexBuilder.java @@ -0,0 +1,98 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jdt.core.dom.FieldDeclaration; +import org.eclipse.jdt.core.dom.Modifier; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.VariableDeclarationFragment; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +@Slf4j +public final class FieldTypeIndexBuilder { + + private CodebaseContext context; + + public FieldTypeIndex build(CodebaseContext context) { + this.context = context; + if (context == null) { + return FieldTypeIndex.empty(); + } + + Map> direct = new HashMap<>(); + for (String ownerFqn : context.getIndexedTypeFqns()) { + TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn); + if (typeDeclaration != null) { + indexDeclaredFields(ownerFqn, typeDeclaration, direct); + } + } + + Map> effective = new HashMap<>(); + int fieldCount = 0; + Map> cache = new HashMap<>(); + for (String ownerFqn : direct.keySet()) { + Map merged = mergeEffective(ownerFqn, direct, cache, new HashSet<>()); + if (!merged.isEmpty()) { + effective.put(ownerFqn, merged); + fieldCount += merged.size(); + } + } + + log.info("Built field-type index: {} fields across {} types", fieldCount, effective.size()); + this.context = null; + return new FieldTypeIndex(effective, fieldCount); + } + + private void indexDeclaredFields( + String ownerFqn, + TypeDeclaration typeDeclaration, + Map> direct) { + Map fields = direct.computeIfAbsent(ownerFqn, ignored -> new HashMap<>()); + for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) { + String fieldType = fieldDeclaration.getType().toString(); + for (Object fragmentObj : fieldDeclaration.fragments()) { + if (fragmentObj instanceof VariableDeclarationFragment fragment) { + fields.put(fragment.getName().getIdentifier(), fieldType); + } + } + } + for (TypeDeclaration nested : typeDeclaration.getTypes()) { + indexDeclaredFields(context.getFqn(nested), nested, direct); + } + } + + private Map mergeEffective( + String ownerFqn, + Map> direct, + Map> cache, + Set visiting) { + if (cache.containsKey(ownerFqn)) { + return cache.get(ownerFqn); + } + if (!visiting.add(ownerFqn)) { + return Map.of(); + } + + Map merged = new HashMap<>(); + TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn); + if (ownerType != null) { + String superFqn = context.getSuperclassFqn(ownerType); + if (superFqn != null && !"java.lang.Object".equals(superFqn)) { + merged.putAll(mergeEffective(superFqn, direct, cache, visiting)); + } + } + + Map own = direct.get(ownerFqn); + if (own != null) { + merged.putAll(own); + } + + visiting.remove(ownerFqn); + cache.put(ownerFqn, Map.copyOf(merged)); + return merged; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainEdge.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainEdge.java new file mode 100644 index 0000000..f078df7 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainEdge.java @@ -0,0 +1,14 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import org.eclipse.jdt.core.dom.ClassInstanceCreation; + +/** + * Scan-time result for {@code owner.getX()} getter-chain hops: the next receiver type + * and optional {@code new ...()} field initializer discovered on the backing field. + */ +public record GetterChainEdge( + String ownerFqn, + String getterName, + String nextTypeFqn, + ClassInstanceCreation fieldInitializerCic) { +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainIndex.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainIndex.java new file mode 100644 index 0000000..ee5a318 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainIndex.java @@ -0,0 +1,47 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public final class GetterChainIndex { + + private static final GetterChainIndex EMPTY = new GetterChainIndex(Map.of(), 0); + + private final Map> byOwnerAndGetter; + private final int edgeCount; + + public GetterChainIndex(Map> byOwnerAndGetter, int edgeCount) { + this.byOwnerAndGetter = Collections.unmodifiableMap(deepCopy(byOwnerAndGetter)); + this.edgeCount = edgeCount; + } + + public static GetterChainIndex empty() { + return EMPTY; + } + + public Optional lookup(String ownerFqn, String getterName) { + if (ownerFqn == null || getterName == null) { + return Optional.empty(); + } + Map getters = byOwnerAndGetter.get(ownerFqn); + if (getters == null) { + return Optional.empty(); + } + return Optional.ofNullable(getters.get(getterName)); + } + + public int edgeCount() { + return edgeCount; + } + + private static Map> deepCopy( + Map> source) { + Map> copy = new HashMap<>(); + for (Map.Entry> entry : source.entrySet()) { + copy.put(entry.getKey(), Map.copyOf(entry.getValue())); + } + return copy; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainIndexBuilder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainIndexBuilder.java new file mode 100644 index 0000000..3ba7be8 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainIndexBuilder.java @@ -0,0 +1,77 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder; +import click.kamil.springstatemachineexporter.ast.common.AstUtils; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jdt.core.dom.ClassInstanceCreation; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.TypeDeclaration; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +public final class GetterChainIndexBuilder { + + public GetterChainIndex build(CodebaseContext context, AccessorIndex accessorIndex) { + if (context == null || accessorIndex == null) { + return GetterChainIndex.empty(); + } + + FieldInitializerFinder fieldInitializerFinder = new FieldInitializerFinder(context); + Map> edges = new HashMap<>(); + int edgeCount = 0; + + for (Map.Entry> ownerEntry : accessorIndex.asMap().entrySet()) { + String ownerFqn = ownerEntry.getKey(); + for (AccessorSummary accessor : ownerEntry.getValue().values()) { + if (!accessor.isGetter()) { + continue; + } + GetterChainEdge edge = buildEdge(context, fieldInitializerFinder, ownerFqn, accessor); + if (edge == null) { + continue; + } + edges.computeIfAbsent(ownerFqn, ignored -> new HashMap<>()) + .put(accessor.methodName(), edge); + edgeCount++; + } + } + + log.info("Built getter-chain index: {} edges across {} types", edgeCount, edges.size()); + return new GetterChainIndex(edges, edgeCount); + } + + private static GetterChainEdge buildEdge( + CodebaseContext context, + FieldInitializerFinder fieldInitializerFinder, + String ownerFqn, + AccessorSummary accessor) { + String nextType = null; + ClassInstanceCreation fieldInitializerCic = null; + + if (accessor.fieldName() != null) { + fieldInitializerCic = fieldInitializerFinder.findFieldInitializerCic(ownerFqn, accessor.fieldName()); + nextType = fieldInitializerFinder.resolveFieldTypeName(ownerFqn, accessor.fieldName()); + if (fieldInitializerCic != null) { + nextType = AstUtils.extractSimpleTypeName(fieldInitializerCic.getType()); + } + } + + if (nextType == null) { + TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn); + MethodDeclaration getter = ownerType != null + ? context.findMethodDeclaration(ownerType, accessor.methodName(), true) + : null; + if (getter != null && getter.getReturnType2() != null) { + nextType = getter.getReturnType2().toString(); + } + } + + if (nextType == null) { + return null; + } + return new GetterChainEdge(ownerFqn, accessor.methodName(), nextType, fieldInitializerCic); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/IndexedFieldConstantsIndex.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/IndexedFieldConstantsIndex.java new file mode 100644 index 0000000..92c828d --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/IndexedFieldConstantsIndex.java @@ -0,0 +1,54 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Scan-time constants for indexed getters backed by fields with literal or enum initializers. + */ +public final class IndexedFieldConstantsIndex { + + private static final IndexedFieldConstantsIndex EMPTY = new IndexedFieldConstantsIndex(Map.of(), 0); + + private final Map>> byOwnerAndMethod; + private final int entryCount; + + public IndexedFieldConstantsIndex(Map>> byOwnerAndMethod, int entryCount) { + this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod)); + this.entryCount = entryCount; + } + + public static IndexedFieldConstantsIndex empty() { + return EMPTY; + } + + public Optional> lookup(String ownerFqn, String methodName) { + if (ownerFqn == null || methodName == null) { + return Optional.empty(); + } + Map> methods = byOwnerAndMethod.get(ownerFqn); + if (methods == null) { + return Optional.empty(); + } + List constants = methods.get(methodName); + if (constants == null || constants.isEmpty()) { + return Optional.empty(); + } + return Optional.of(constants); + } + + public int entryCount() { + return entryCount; + } + + private static Map>> deepCopy(Map>> source) { + Map>> copy = new HashMap<>(); + for (Map.Entry>> entry : source.entrySet()) { + copy.put(entry.getKey(), Map.copyOf(entry.getValue())); + } + return copy; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/IndexedFieldConstantsIndexBuilder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/IndexedFieldConstantsIndexBuilder.java new file mode 100644 index 0000000..a217762 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/IndexedFieldConstantsIndexBuilder.java @@ -0,0 +1,41 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +public final class IndexedFieldConstantsIndexBuilder { + + public IndexedFieldConstantsIndex build(CodebaseContext context, AccessorIndex accessorIndex) { + if (context == null || accessorIndex == null) { + return IndexedFieldConstantsIndex.empty(); + } + + Map>> entries = new HashMap<>(); + int entryCount = 0; + + for (Map.Entry> ownerEntry : accessorIndex.asMap().entrySet()) { + String ownerFqn = ownerEntry.getKey(); + for (AccessorSummary accessor : ownerEntry.getValue().values()) { + if (!accessor.isGetter() || accessor.fieldName() == null) { + continue; + } + List constants = ScanTimeAccessorConstants.resolve( + context, accessorIndex, ownerFqn, accessor.methodName()); + if (constants.isEmpty()) { + continue; + } + entries.computeIfAbsent(ownerFqn, ignored -> new HashMap<>()) + .put(accessor.methodName(), List.copyOf(constants)); + entryCount++; + } + } + + log.info("Built indexed field-constants index: {} entries across {} types", entryCount, entries.size()); + return new IndexedFieldConstantsIndex(entries, entryCount); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorEntry.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorEntry.java new file mode 100644 index 0000000..228adab --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorEntry.java @@ -0,0 +1,16 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import java.util.List; +import java.util.Optional; + +/** + * Scan-time summary of how a getter on an interface or abstract type resolves across + * its concrete implementations. Values are derived from static analysis only. + */ +public record PolymorphicAccessorEntry( + String ownerFqn, + String methodName, + Optional representativeImplAccessor, + List widenedReturnConstants, + boolean staticallyComplete) { +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorIndex.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorIndex.java new file mode 100644 index 0000000..63af8c9 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorIndex.java @@ -0,0 +1,62 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public final class PolymorphicAccessorIndex { + + private static final PolymorphicAccessorIndex EMPTY = new PolymorphicAccessorIndex(Map.of(), 0); + + private final Map> byOwnerAndMethod; + private final int entryCount; + + public PolymorphicAccessorIndex( + Map> byOwnerAndMethod, + int entryCount) { + this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod)); + this.entryCount = entryCount; + } + + public static PolymorphicAccessorIndex empty() { + return EMPTY; + } + + public Optional lookup(String ownerFqn, String methodName) { + if (ownerFqn == null || methodName == null) { + return Optional.empty(); + } + Map methods = byOwnerAndMethod.get(ownerFqn); + if (methods == null) { + return Optional.empty(); + } + return Optional.ofNullable(methods.get(methodName)); + } + + public Optional representativeImplAccessor(String ownerFqn, String methodName) { + return lookup(ownerFqn, methodName) + .flatMap(PolymorphicAccessorEntry::representativeImplAccessor); + } + + public Optional> widenedReturnConstants(String ownerFqn, String methodName) { + return lookup(ownerFqn, methodName) + .filter(PolymorphicAccessorEntry::staticallyComplete) + .map(PolymorphicAccessorEntry::widenedReturnConstants) + .filter(values -> !values.isEmpty()); + } + + public int entryCount() { + return entryCount; + } + + private static Map> deepCopy( + Map> source) { + Map> copy = new HashMap<>(); + for (Map.Entry> entry : source.entrySet()) { + copy.put(entry.getKey(), Map.copyOf(entry.getValue())); + } + return copy; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorIndexBuilder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorIndexBuilder.java new file mode 100644 index 0000000..bd54885 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorIndexBuilder.java @@ -0,0 +1,156 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.Modifier; +import org.eclipse.jdt.core.dom.TypeDeclaration; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +@Slf4j +public final class PolymorphicAccessorIndexBuilder { + + public PolymorphicAccessorIndex build(CodebaseContext context, AccessorIndex accessorIndex) { + if (context == null || accessorIndex == null) { + return PolymorphicAccessorIndex.empty(); + } + + Map> entries = new HashMap<>(); + int entryCount = 0; + + for (String typeFqn : context.getIndexedTypeFqns()) { + TypeDeclaration typeDeclaration = context.getTypeDeclaration(typeFqn); + if (!isPolymorphicType(typeDeclaration)) { + continue; + } + List implementations = context.getImplementations(typeFqn); + if (implementations.isEmpty()) { + continue; + } + + Set methodNames = collectGetterMethodNames(context, accessorIndex, typeFqn, implementations); + for (String methodName : methodNames) { + PolymorphicAccessorEntry entry = buildEntry( + context, accessorIndex, typeFqn, methodName, implementations); + if (entry == null) { + continue; + } + entries.computeIfAbsent(typeFqn, ignored -> new HashMap<>()).put(methodName, entry); + entryCount++; + } + } + + log.info("Built polymorphic accessor index: {} entries across {} types", entryCount, entries.size()); + return new PolymorphicAccessorIndex(entries, entryCount); + } + + private static PolymorphicAccessorEntry buildEntry( + CodebaseContext context, + AccessorIndex accessorIndex, + String ownerFqn, + String methodName, + List implementations) { + LinkedHashSet widenedConstants = new LinkedHashSet<>(); + widenedConstants.addAll(ScanTimeAccessorConstants.resolve(context, accessorIndex, ownerFqn, methodName)); + + Optional representative = findRepresentativeImplAccessor(accessorIndex, implementations, methodName); + for (String implementationFqn : implementations) { + widenedConstants.addAll( + ScanTimeAccessorConstants.resolve(context, accessorIndex, implementationFqn, methodName)); + } + + if (representative.isEmpty() && widenedConstants.isEmpty()) { + return null; + } + boolean staticallyComplete = isStaticallyComplete(accessorIndex, methodName, implementations); + return new PolymorphicAccessorEntry( + ownerFqn, + methodName, + representative, + List.copyOf(widenedConstants), + staticallyComplete); + } + + private static boolean isStaticallyComplete( + AccessorIndex accessorIndex, + String methodName, + List implementations) { + for (String implementationFqn : implementations) { + if (!isConstantLikeAccessor(accessorIndex, implementationFqn, methodName)) { + return false; + } + } + return true; + } + + private static boolean isConstantLikeAccessor( + AccessorIndex accessorIndex, + String typeFqn, + String methodName) { + Optional accessor = accessorIndex.lookup(typeFqn, methodName); + if (accessor.isEmpty() || !accessor.get().isGetter()) { + return false; + } + return accessor.get().kind() == AccessorKind.CONSTANT_GETTER; + } + + private static Optional findRepresentativeImplAccessor( + AccessorIndex accessorIndex, + List implementations, + String methodName) { + for (String implementationFqn : implementations) { + Optional implAccessor = accessorIndex.lookup(implementationFqn, methodName); + if (implAccessor.isPresent() && implAccessor.get().isGetter()) { + return implAccessor; + } + } + return Optional.empty(); + } + + private static Set collectGetterMethodNames( + CodebaseContext context, + AccessorIndex accessorIndex, + String ownerFqn, + List implementations) { + Set methodNames = new LinkedHashSet<>(); + for (AccessorSummary accessor : accessorIndex.accessorsForType(ownerFqn)) { + if (accessor.isGetter()) { + methodNames.add(accessor.methodName()); + } + } + TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn); + if (ownerType != null) { + for (MethodDeclaration method : ownerType.getMethods()) { + String name = method.getName().getIdentifier(); + if (looksLikeGetterName(name)) { + methodNames.add(name); + } + } + } + for (String implementationFqn : implementations) { + for (AccessorSummary accessor : accessorIndex.accessorsForType(implementationFqn)) { + if (accessor.isGetter()) { + methodNames.add(accessor.methodName()); + } + } + } + return methodNames; + } + + private static boolean isPolymorphicType(TypeDeclaration typeDeclaration) { + return typeDeclaration != null + && (typeDeclaration.isInterface() + || Modifier.isAbstract(typeDeclaration.getModifiers())); + } + + private static boolean looksLikeGetterName(String methodName) { + return methodName.startsWith("get") || methodName.startsWith("is"); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicMethodExpansionIndex.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicMethodExpansionIndex.java new file mode 100644 index 0000000..fa266d5 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicMethodExpansionIndex.java @@ -0,0 +1,58 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Scan-time expansion of interface/abstract method calls to concrete implementation method FQNs. + */ +public final class PolymorphicMethodExpansionIndex { + + private static final PolymorphicMethodExpansionIndex EMPTY = + new PolymorphicMethodExpansionIndex(Map.of(), 0); + + private final Map>> byOwnerAndMethod; + private final int entryCount; + + public PolymorphicMethodExpansionIndex( + Map>> byOwnerAndMethod, + int entryCount) { + this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod)); + this.entryCount = entryCount; + } + + public static PolymorphicMethodExpansionIndex empty() { + return EMPTY; + } + + public Optional> lookup(String ownerFqn, String methodName) { + if (ownerFqn == null || methodName == null) { + return Optional.empty(); + } + Map> methods = byOwnerAndMethod.get(ownerFqn); + if (methods == null) { + return Optional.empty(); + } + List expanded = methods.get(methodName); + if (expanded == null || expanded.isEmpty()) { + return Optional.empty(); + } + return Optional.of(expanded); + } + + public int entryCount() { + return entryCount; + } + + private static Map>> deepCopy( + Map>> source) { + Map>> copy = new HashMap<>(); + for (Map.Entry>> entry : source.entrySet()) { + copy.put(entry.getKey(), Map.copyOf(entry.getValue())); + } + return copy; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicMethodExpansionIndexBuilder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicMethodExpansionIndexBuilder.java new file mode 100644 index 0000000..ab888a6 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicMethodExpansionIndexBuilder.java @@ -0,0 +1,81 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jdt.core.dom.Modifier; +import org.eclipse.jdt.core.dom.TypeDeclaration; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Slf4j +public final class PolymorphicMethodExpansionIndexBuilder { + + public PolymorphicMethodExpansionIndex build(CodebaseContext context) { + if (context == null) { + return PolymorphicMethodExpansionIndex.empty(); + } + + Map>> entries = new HashMap<>(); + int entryCount = 0; + + for (String typeFqn : context.getIndexedTypeFqns()) { + TypeDeclaration typeDeclaration = context.getTypeDeclaration(typeFqn); + if (!isPolymorphicType(typeDeclaration)) { + continue; + } + List implementations = context.getImplementations(typeFqn); + if (implementations.isEmpty()) { + continue; + } + + Set methodNames = collectMethodNames(context, typeDeclaration, implementations); + for (String methodName : methodNames) { + List expanded = new ArrayList<>(); + for (String implementationFqn : implementations) { + expanded.add(implementationFqn + "." + methodName); + } + entries.computeIfAbsent(typeFqn, ignored -> new HashMap<>()) + .put(methodName, List.copyOf(expanded)); + entryCount++; + } + } + + log.info("Built polymorphic method expansion index: {} entries across {} types", + entryCount, entries.size()); + return new PolymorphicMethodExpansionIndex(entries, entryCount); + } + + private static Set collectMethodNames( + CodebaseContext context, + TypeDeclaration ownerType, + List implementations) { + Set methodNames = new LinkedHashSet<>(); + if (ownerType != null) { + for (var method : ownerType.getMethods()) { + methodNames.add(method.getName().getIdentifier()); + } + } + for (String implementationFqn : implementations) { + TypeDeclaration implementationType = context.getTypeDeclaration(implementationFqn); + if (implementationType == null) { + continue; + } + for (var method : implementationType.getMethods()) { + methodNames.add(method.getName().getIdentifier()); + } + } + return methodNames; + } + + private static boolean isPolymorphicType(TypeDeclaration typeDeclaration) { + return typeDeclaration != null + && (typeDeclaration.isInterface() + || Modifier.isAbstract(typeDeclaration.getModifiers())); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/ScanTimeAccessorConstants.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/ScanTimeAccessorConstants.java new file mode 100644 index 0000000..0fbeb2a --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/ScanTimeAccessorConstants.java @@ -0,0 +1,162 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.FieldDeclaration; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.ReturnStatement; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.VariableDeclarationFragment; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Resolves accessor return constants using only scan-time information: indexed accessors, + * literal method bodies, and field initializers. No dataflow or constructor tracing. + */ +public final class ScanTimeAccessorConstants { + + private ScanTimeAccessorConstants() { + } + + public static List resolve( + CodebaseContext context, + AccessorIndex accessorIndex, + String ownerFqn, + String methodName) { + if (context == null || accessorIndex == null || ownerFqn == null || methodName == null) { + return List.of(); + } + Set deduped = new LinkedHashSet<>(); + Optional indexed = accessorIndex.lookup(ownerFqn, methodName); + if (indexed.isPresent() && indexed.get().isGetter()) { + if (indexed.get().kind() == AccessorKind.CONSTANT_GETTER) { + deduped.addAll(extractReturnConstants(context, ownerFqn, methodName)); + } else if (indexed.get().fieldName() != null) { + deduped.addAll(extractFieldInitializerConstants( + context, ownerFqn, indexed.get().fieldName())); + } + } + if (deduped.isEmpty()) { + deduped.addAll(extractReturnConstants(context, ownerFqn, methodName)); + } + return List.copyOf(deduped); + } + + private static List extractReturnConstants( + CodebaseContext context, + String ownerFqn, + String methodName) { + TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn); + if (typeDeclaration == null) { + return List.of(); + } + MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true); + if (methodDeclaration == null || methodDeclaration.getBody() == null) { + return List.of(); + } + List constants = new ArrayList<>(); + ConstantResolver constantResolver = context.getConstantResolver(); + methodDeclaration.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(ReturnStatement node) { + Expression expression = node.getExpression(); + if (expression != null) { + addResolvedExpression(constantResolver, context, expression, constants); + } + return false; + } + }); + return constants; + } + + private static List extractFieldInitializerConstants( + CodebaseContext context, + String ownerFqn, + String fieldName) { + TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn); + if (typeDeclaration == null) { + return List.of(); + } + List constants = new ArrayList<>(); + collectFieldInitializerConstants(context, typeDeclaration, fieldName, constants); + return constants; + } + + private static void collectFieldInitializerConstants( + CodebaseContext context, + TypeDeclaration typeDeclaration, + String fieldName, + List constants) { + ConstantResolver constantResolver = context.getConstantResolver(); + for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) { + for (Object fragmentObj : fieldDeclaration.fragments()) { + if (fragmentObj instanceof VariableDeclarationFragment fragment + && fragment.getName().getIdentifier().equals(fieldName) + && fragment.getInitializer() != null) { + addResolvedExpression( + constantResolver, context, fragment.getInitializer(), constants); + } + } + } + if (constants.isEmpty()) { + String superFqn = context.getSuperclassFqn(typeDeclaration); + if (superFqn != null && !"java.lang.Object".equals(superFqn)) { + TypeDeclaration superType = context.getTypeDeclaration(superFqn); + if (superType != null) { + collectFieldInitializerConstants(context, superType, fieldName, constants); + } + } + } + } + + private static void addResolvedExpression( + ConstantResolver constantResolver, + CodebaseContext context, + Expression expression, + List constants) { + if (constantResolver == null || expression == null) { + return; + } + String resolved = constantResolver.resolve(expression, context); + if (resolved == null) { + return; + } + if (resolved.startsWith("ENUM_SET:")) { + for (String value : resolved.substring(9).split(",")) { + addUnique(constants, parseEnumSetElement(value)); + } + } else if (resolved.startsWith("MAP:")) { + List mapValues = ConstantResolver.decodeMapLiteralValues(resolved); + if (mapValues != null) { + for (String mapValue : mapValues) { + addUnique(constants, mapValue); + } + } + } else if (resolved.startsWith("ARRAY:")) { + for (String arrayValue : resolved.substring(6).split("\\|", -1)) { + addUnique(constants, arrayValue); + } + } else { + addUnique(constants, resolved); + } + } + + private static String parseEnumSetElement(String value) { + int lastDot = value.lastIndexOf('.'); + int secondLastDot = lastDot > 0 ? value.lastIndexOf('.', lastDot - 1) : -1; + return secondLastDot >= 0 ? value.substring(secondLastDot + 1) : value; + } + + private static void addUnique(List constants, String value) { + if (value != null && !constants.contains(value)) { + constants.add(value); + } + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorResolver.java index aacb25d..cca9bad 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorResolver.java @@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.pipeline; import click.kamil.springstatemachineexporter.analysis.index.AccessorKind; import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; +import click.kamil.springstatemachineexporter.analysis.index.GetterChainEdge; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer; import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor; @@ -46,6 +47,7 @@ public final class AccessorResolver { private final ConstantResolver constantResolver; private final FieldInitializerFinder fieldInitializerFinder; private final Map> sessionCache = new HashMap<>(); + private final Map> indexedGetterCache = new HashMap<>(); public AccessorResolver( CodebaseContext context, @@ -63,6 +65,16 @@ public final class AccessorResolver { if (className == null || className.isBlank()) { return Optional.empty(); } + String cacheKey = className + "#" + methodName; + if (indexedGetterCache.containsKey(cacheKey)) { + return indexedGetterCache.get(cacheKey); + } + Optional resolved = lookupIndexedGetterInternal(className, methodName); + indexedGetterCache.put(cacheKey, resolved); + return resolved; + } + + private Optional lookupIndexedGetterInternal(String className, String methodName) { Optional direct = context.getAccessorIndex().lookup(className, methodName); if (direct.isPresent()) { return direct; @@ -78,9 +90,14 @@ public final class AccessorResolver { } } List implementations = context.getImplementations(className); - if (implementations == null) { + if (implementations == null || implementations.isEmpty()) { return Optional.empty(); } + Optional precomputed = context.getPolymorphicAccessorIndex() + .representativeImplAccessor(className, methodName); + if (precomputed.isPresent()) { + return precomputed; + } for (String implementation : implementations) { Optional implAccessor = context.getAccessorIndex().lookup(implementation, methodName); if (implAccessor.isPresent()) { @@ -114,7 +131,7 @@ public final class AccessorResolver { } List resolved = resolveInternal(ownerFqn, methodName, receiverContext, budget, visited); - if (!resolved.isEmpty()) { + if (!resolved.isEmpty() || isStaticCacheable(receiverContext)) { List cachedCopy = List.copyOf(resolved); sessionCache.put(cacheKey, cachedCopy); return cachedCopy; @@ -127,6 +144,12 @@ public final class AccessorResolver { return null; } + Optional precomputed = context.getGetterChainIndex().lookup(ownerFqn, getterName); + if (precomputed.isPresent()) { + GetterChainEdge edge = precomputed.get(); + return new GetterChainStep(edge.nextTypeFqn(), edge.fieldInitializerCic()); + } + Optional indexedAccessor = lookupIndexedGetter(ownerFqn, getterName); if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) { AccessorSummary accessor = indexedAccessor.get(); @@ -165,8 +188,20 @@ public final class AccessorResolver { return null; } + private static boolean isStaticCacheable(ReceiverContext receiverContext) { + if (receiverContext == null) { + return true; + } + if (receiverContext.cic() != null) { + return false; + } + Map fieldValues = receiverContext.fieldValues(); + return fieldValues == null || fieldValues.isEmpty(); + } + public void clearSessionCache() { sessionCache.clear(); + indexedGetterCache.clear(); } private List resolveInternal( diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ClassCompatibilityCache.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ClassCompatibilityCache.java new file mode 100644 index 0000000..047adc9 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ClassCompatibilityCache.java @@ -0,0 +1,40 @@ +package click.kamil.springstatemachineexporter.analysis.pipeline; + +import java.util.AbstractMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Memoizes polymorphic class compatibility checks used during call-graph path matching. + */ +public final class ClassCompatibilityCache { + + private final Map, Boolean> cache = new ConcurrentHashMap<>(); + + public boolean areClassesCompatible(String classNeighbor, String classTarget, ClassCompatibilityChecker checker) { + if (classNeighbor == null || classTarget == null) { + return false; + } + if (classNeighbor.equals(classTarget)) { + return true; + } + Map.Entry key = cacheKey(classNeighbor, classTarget); + return cache.computeIfAbsent(key, ignored -> checker.check(classNeighbor, classTarget)); + } + + public void clear() { + cache.clear(); + } + + private static Map.Entry cacheKey(String classNeighbor, String classTarget) { + if (classNeighbor.compareTo(classTarget) <= 0) { + return new AbstractMap.SimpleImmutableEntry<>(classNeighbor, classTarget); + } + return new AbstractMap.SimpleImmutableEntry<>(classTarget, classNeighbor); + } + + @FunctionalInterface + public interface ClassCompatibilityChecker { + boolean check(String classNeighbor, String classTarget); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index b015a82..51e69af 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -32,6 +32,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { protected final FieldInitializerFinder fieldInitializerFinder; protected final PathBindingEvaluator pathBindingEvaluator; private final Map parsedNodeCache = new HashMap<>(); + private final Map> polymorphicCallCache = new HashMap<>(); private ASTNode parseExpressionString(String expr) { if (expr == null) return null; @@ -97,6 +98,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } public List findChains(List entryPoints, List triggers) { + accessorResolver.clearSessionCache(); + constructorAnalyzer.clearReturnedCicCache(); + variableTracer.clearAnalysisCaches(); + polymorphicCallCache.clear(); + context.clearAnalysisCaches(); + pathFinder.clearAnalysisCaches(); + parsedNodeCache.clear(); Map> callGraph = buildCallGraph(); List chains = new ArrayList<>(); @@ -628,7 +636,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } else if (exprNode instanceof ClassInstanceCreation cic) { declaredType = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType()); sourceMethod = "inline-instantiation"; - polymorphicEvents.add(declaredType); + extractEventConstantsFromConstructor(cic, polymorphicEvents); + if (polymorphicEvents.isEmpty()) { + polymorphicEvents.add(declaredType); + } } else if (exprNode instanceof ConditionalExpression cond) { List branches = new ArrayList<>(); branches.add(cond.getThenExpression()); @@ -703,12 +714,18 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { ce.getExpression() instanceof ClassInstanceCreation cic) { declaredType = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType()); sourceMethod = "inline-instantiation"; - polymorphicEvents.add(declaredType); + extractEventConstantsFromConstructor(cic, polymorphicEvents); + if (polymorphicEvents.isEmpty()) { + polymorphicEvents.add(declaredType); + } } else if (exprNode instanceof CastExpression ce && ce.getExpression() instanceof ClassInstanceCreation cic) { declaredType = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType()); sourceMethod = "inline-instantiation"; - polymorphicEvents.add(declaredType); + extractEventConstantsFromConstructor(cic, polymorphicEvents); + if (polymorphicEvents.isEmpty()) { + polymorphicEvents.add(declaredType); + } } if (methodName != null) { @@ -887,7 +904,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { cuToUse = (CompilationUnit) entryTd.getRoot(); } } - List constants = constantExtractor.resolveMethodReturnConstant(type, methodName, 0, visited, cuToUse); + List constants = constantExtractor.resolveMethodReturnConstant(type, methodName, 0, visited, cuToUse, ResolutionBudget.defaults(), false); for (String constant : constants) { if (!polymorphicEvents.contains(constant)) { polymorphicEvents.add(constant); @@ -958,6 +975,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } return false; }); + qualifyBareEnumConstants(polymorphicEvents, expType); } List unpacked = new ArrayList<>(); @@ -1127,9 +1145,365 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } protected String postProcessResolvedArgument(Expression originalExpr, String resolvedValue) { + // Preserve valueOf(variable) so trigger tracing can bind the variable per call-path + // instead of widening to all enum constants at graph-build time. + if (originalExpr instanceof MethodInvocation valueOfMi + && "valueOf".equals(valueOfMi.getName().getIdentifier()) + && valueOfMi.arguments().size() == 1 + && valueOfMi.arguments().get(0) instanceof SimpleName) { + return originalExpr.toString(); + } + + // Preserve field-wired helper calls (routingHelper.payAction()) for per-endpoint literal forwarding. + if (originalExpr instanceof MethodInvocation helperMi + && helperMi.getExpression() instanceof SimpleName helperReceiver + && !helperReceiver.getIdentifier().isEmpty() + && Character.isLowerCase(helperReceiver.getIdentifier().charAt(0)) + && helperMi.arguments().isEmpty() + && resolvedValue != null + && resolvedValue.matches("^[A-Z_][A-Z0-9_]*$")) { + return originalExpr.toString(); + } + + // Preserve getter calls on 'this' or 'super' for later resolution in trigger parameter tracing + // where we can substitute the actual receiver from the call edge. + boolean isThisOrSuperGetter = (originalExpr instanceof MethodInvocation mi + && (mi.getExpression() instanceof ThisExpression || mi.getExpression() instanceof SuperMethodInvocation) + && mi.arguments().isEmpty()) || (originalExpr instanceof SuperMethodInvocation smi && smi.arguments().isEmpty()); + + if (isThisOrSuperGetter) { + return originalExpr.toString(); + } + + // Narrow resolution for "localVar.getX()" patterns where localVar is initialized + // from a ClassInstanceCreation (directly or via a factory method). + if (originalExpr instanceof MethodInvocation getterMi + && getterMi.getExpression() instanceof SimpleName instanceSn + && getterMi.arguments().isEmpty()) { + String narrowed = tryNarrowGetterViaLocalVarChain(getterMi, instanceSn); + if (narrowed != null) { + return narrowed; + } + } + return resolvedValue; } + protected String getReceiverString(Expression receiver) { + if (receiver == null) { + return null; + } + if (receiver instanceof ThisExpression) { + return "this"; + } + if (receiver instanceof SuperMethodInvocation) { + return "super"; + } + if (receiver instanceof SimpleName sn) { + return sn.getIdentifier(); + } + return receiver.toString(); + } + + protected String findCallerReceiver(String callerFqn, String targetMethodFqn) { + int lastDot = callerFqn.lastIndexOf('.'); + if (lastDot < 0) { + return null; + } + String callerClass = callerFqn.substring(0, lastDot); + String callerMethod = callerFqn.substring(lastDot + 1); + + int targetLastDot = targetMethodFqn.lastIndexOf('.'); + if (targetLastDot < 0) { + return null; + } + String targetMethodName = targetMethodFqn.substring(targetLastDot + 1); + + TypeDeclaration callerTd = context.getTypeDeclaration(callerClass); + if (callerTd == null) { + return null; + } + + MethodDeclaration callerMd = context.findMethodDeclaration(callerTd, callerMethod, false); + if (callerMd == null || callerMd.getBody() == null) { + return null; + } + + final String[] foundReceiver = {null}; + callerMd.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (foundReceiver[0] != null) { + return false; + } + String called = resolveCalledMethod(node); + if (called != null && (called.equals(targetMethodFqn) || called.endsWith("." + targetMethodName))) { + foundReceiver[0] = getReceiverString(node.getExpression()); + return false; + } + return super.visit(node); + } + }); + return foundReceiver[0]; + } + + protected String resolveReceiverTypeFallback(SimpleName receiverNameNode) { + String varName = receiverNameNode.getIdentifier(); + + CompilationUnit cu = receiverNameNode.getRoot() instanceof CompilationUnit rootCu ? rootCu : null; + TypeDeclaration staticTd = context.getTypeDeclaration(varName, cu); + if (staticTd != null) { + return context.getFqn(staticTd); + } + TypeDeclaration fallbackStaticTd = context.getTypeDeclaration(varName); + if (fallbackStaticTd != null) { + return context.getFqn(fallbackStaticTd); + } + + MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode); + if (enclosingMethod != null) { + for (Object paramObj : enclosingMethod.parameters()) { + SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj; + if (svd.getName().getIdentifier().equals(varName)) { + return resolveTypeToFqn(svd.getType(), receiverNameNode); + } + } + if (enclosingMethod.getBody() != null) { + Type[] foundType = new Type[1]; + enclosingMethod.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationStatement node) { + for (Object fragObj : node.fragments()) { + VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; + if (frag.getName().getIdentifier().equals(varName)) { + foundType[0] = node.getType(); + } + } + return super.visit(node); + } + }); + if (foundType[0] != null) { + return resolveTypeToFqn(foundType[0], receiverNameNode); + } + } + } + + TypeDeclaration enclosingType = findEnclosingType(receiverNameNode); + TypeDeclaration currentType = enclosingType; + while (currentType != null) { + for (FieldDeclaration field : currentType.getFields()) { + for (Object fragObj : field.fragments()) { + VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; + if (frag.getName().getIdentifier().equals(varName)) { + return resolveTypeToFqn(field.getType(), receiverNameNode); + } + } + } + String superclass = context.getSuperclassFqn(currentType); + currentType = superclass != null ? context.getTypeDeclaration(superclass) : null; + } + + return null; + } + + protected String resolveTypeToFqn(Type type, ASTNode contextNode) { + if (type == null) { + return null; + } + String simpleName; + if (type.isSimpleType()) { + simpleName = ((SimpleType) type).getName().getFullyQualifiedName(); + } else if (type.isParameterizedType()) { + return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode); + } else { + simpleName = type.toString(); + } + + CompilationUnit cu = (CompilationUnit) contextNode.getRoot(); + TypeDeclaration td = context.getTypeDeclaration(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; + } + } + + return simpleName; + } + + private String tryNarrowGetterViaLocalVarChain(MethodInvocation getterCall, SimpleName instanceSn) { + MethodDeclaration enclosing = findEnclosingMethod(getterCall); + if (enclosing == null || enclosing.getBody() == null) { + return null; + } + + String varName = instanceSn.getIdentifier(); + String getterName = getterCall.getName().getIdentifier(); + + String[] varTypeName = {null}; + Expression[] initExpr = {null}; + enclosing.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationStatement vds) { + for (Object fragObj : vds.fragments()) { + VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; + if (frag.getName().getIdentifier().equals(varName)) { + varTypeName[0] = vds.getType().toString(); + initExpr[0] = frag.getInitializer(); + } + } + return super.visit(vds); + } + }); + if (varTypeName[0] == null) { + return null; + } + + ClassInstanceCreation cic = null; + String receiverType = null; + if (initExpr[0] instanceof ClassInstanceCreation directCic) { + cic = directCic; + } else if (initExpr[0] instanceof MethodInvocation factoryMi + && (factoryMi.getExpression() instanceof SimpleName || factoryMi.getExpression() instanceof QualifiedName)) { + if (factoryMi.getExpression() instanceof SimpleName receiverSn) { + receiverType = resolveReceiverTypeFallback(receiverSn); + } else if (factoryMi.getExpression() instanceof QualifiedName qn) { + receiverType = qn.getFullyQualifiedName(); + } + if (receiverType != null) { + cic = findReturnedCIC(receiverType, factoryMi.getName().getIdentifier(), context); + } + } + if (cic == null) { + if (initExpr[0] instanceof MethodInvocation initMi) { + String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName; + Expression currentMi = initMi; + while (currentMi instanceof MethodInvocation chainMi) { + String mName = chainMi.getName().getIdentifier(); + if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) { + if (!chainMi.arguments().isEmpty()) { + return chainMi.arguments().get(0).toString(); + } + } + currentMi = chainMi.getExpression(); + } + } + return null; + } + + CompilationUnit cu = (CompilationUnit) getterCall.getRoot(); + TypeDeclaration varTypeTd = context.getTypeDeclaration(varTypeName[0], cu); + if (varTypeTd == null) { + varTypeTd = context.getTypeDeclaration(varTypeName[0]); + } + if (varTypeTd == null) { + return null; + } + + Map initialLocals = new HashMap<>(); + if (initExpr[0] instanceof MethodInvocation factoryMi) { + MethodDeclaration md = context.findMethodDeclaration( + context.getTypeDeclaration(receiverType != null ? receiverType : varTypeName[0]), + factoryMi.getName().getIdentifier(), + true); + if (md != null) { + for (int i = 0; i < md.parameters().size() && i < factoryMi.arguments().size(); i++) { + SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i); + Expression arg = (Expression) factoryMi.arguments().get(i); + String resolvedVal = constantResolver.resolve(arg, context); + if (resolvedVal != null) { + initialLocals.put(param.getName().getIdentifier(), resolvedVal); + } + } + } + } + + Map fieldValues = constructorAnalyzer.buildFieldValuesFromCIC( + varTypeTd, cic, context, this::evaluateMethodCallInConstructor, initialLocals); + if (fieldValues.isEmpty()) { + return null; + } + + trackSetterCallsBetween(enclosing.getBody(), varName, getterCall, fieldValues, context); + + MethodDeclaration getter = context.findMethodDeclaration(varTypeTd, getterName, true); + if (getter == null || getter.getBody() == null) { + return null; + } + + return constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>()); + } + + private void trackSetterCallsBetween( + Block methodBody, + String varName, + MethodInvocation getterCall, + Map fieldValues, + CodebaseContext context) { + List statements = new ArrayList<>(); + for (Object stmtObj : methodBody.statements()) { + statements.add((ASTNode) stmtObj); + } + + int varDeclIndex = -1; + int getterCallIndex = -1; + for (int i = 0; i < statements.size(); i++) { + ASTNode stmt = statements.get(i); + if (varDeclIndex == -1 && stmt instanceof VariableDeclarationStatement vds) { + for (Object fragObj : vds.fragments()) { + if (fragObj instanceof VariableDeclarationFragment frag + && frag.getName().getIdentifier().equals(varName)) { + varDeclIndex = i; + break; + } + } + } + if (getterCallIndex == -1 && stmt.toString().contains(getterCall.toString())) { + getterCallIndex = i; + break; + } + } + + if (varDeclIndex < 0 || getterCallIndex <= varDeclIndex) { + return; + } + + for (int i = varDeclIndex + 1; i < getterCallIndex; i++) { + ASTNode stmt = statements.get(i); + if (stmt instanceof ExpressionStatement es) { + Expression expr = es.getExpression(); + if (expr instanceof MethodInvocation mi) { + if (mi.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) { + String methodName = mi.getName().getIdentifier(); + if (methodName.startsWith("set") && methodName.length() > 3 && !mi.arguments().isEmpty()) { + String fieldName = Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4); + String val = constantResolver.resolve((Expression) mi.arguments().get(0), context); + if (val != null) { + fieldValues.put(fieldName, val); + fieldValues.put("this." + fieldName, val); + } + } + } + } else if (expr instanceof Assignment assignment) { + if (assignment.getLeftHandSide() instanceof FieldAccess fa) { + if (fa.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) { + String val = constantResolver.resolve(assignment.getRightHandSide(), context); + if (val != null) { + fieldValues.put(fa.getName().getIdentifier(), val); + fieldValues.put("this." + fa.getName().getIdentifier(), val); + } + } + } + } + } + } + } + protected String resolveMethodInType(TypeDeclaration td, String methodName) { MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); if (md != null) { @@ -1874,49 +2248,60 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String baseCalled = resolveCalledMethod(node); if (baseCalled == null) return Collections.emptyList(); - List allResolved = new ArrayList<>(); - if (node.getExpression() == null) { return Collections.singletonList(baseCalled); } - if (baseCalled.contains(".")) { - String className = baseCalled.substring(0, baseCalled.lastIndexOf('.')); - String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1); - - TypeDeclaration td = context.getTypeDeclaration(className); - List impls = context.getImplementations(className); - - boolean shouldExpand = true; - if (impls.isEmpty()) { - shouldExpand = false; - } else if (td == null) { - // Do not polymorphically expand external generic types (e.g. java.lang.Runnable, Spring Action) - // across the entire codebase. This prevents massive false-positive tangling. - shouldExpand = false; - } else { - boolean isImplicitThis = node.getExpression() instanceof ThisExpression; - if (!td.isInterface() && !Modifier.isAbstract(td.getModifiers()) && isImplicitThis) { - shouldExpand = false; - } - } + if (!baseCalled.contains(".")) { + return Collections.singletonList(baseCalled); + } - if (shouldExpand) { - boolean isAbstractOrInterface = td.isInterface() || Modifier.isAbstract(td.getModifiers()); + String className = baseCalled.substring(0, baseCalled.lastIndexOf('.')); + String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1); + TypeDeclaration td = context.getTypeDeclaration(className); + boolean isImplicitThis = node.getExpression() instanceof ThisExpression; + String cacheKey = baseCalled + "#" + isImplicitThis; + List cached = polymorphicCallCache.get(cacheKey); + if (cached != null) { + return cached; + } + + List allResolved = new ArrayList<>(); + List impls = context.getImplementations(className); + + boolean shouldExpand = true; + if (impls.isEmpty()) { + shouldExpand = false; + } else if (td == null) { + shouldExpand = false; + } else if (!td.isInterface() && !Modifier.isAbstract(td.getModifiers()) && isImplicitThis) { + shouldExpand = false; + } + + if (shouldExpand) { + boolean isAbstractOrInterface = td.isInterface() || Modifier.isAbstract(td.getModifiers()); + Optional> precomputed = + context.getPolymorphicMethodExpansionIndex().lookup(className, methodName); + if (precomputed.isPresent()) { + if (!isAbstractOrInterface) { + allResolved.add(baseCalled); + } + allResolved.addAll(precomputed.get()); + } else { if (!isAbstractOrInterface) { allResolved.add(baseCalled); } for (String impl : impls) { allResolved.add(impl + "." + methodName); } - } else { - allResolved.add(baseCalled); } } else { allResolved.add(baseCalled); } - return allResolved; + List frozen = List.copyOf(allResolved); + polymorphicCallCache.put(cacheKey, frozen); + return frozen; } /** @@ -1958,6 +2343,44 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return current; } + private void qualifyBareEnumConstants(List polymorphicEvents, String expectedType) { + if (expectedType == null || polymorphicEvents == null || polymorphicEvents.isEmpty()) { + return; + } + List enumValues = context.getEnumValues(expectedType); + String simpleExpectedType = expectedType; + if (enumValues == null) { + simpleExpectedType = expectedType.contains(".") + ? expectedType.substring(expectedType.lastIndexOf('.') + 1) + : expectedType; + enumValues = context.getEnumValues(simpleExpectedType); + } else if (expectedType.contains(".")) { + simpleExpectedType = expectedType.substring(expectedType.lastIndexOf('.') + 1); + } + if (enumValues == null || enumValues.isEmpty()) { + return; + } + for (int i = 0; i < polymorphicEvents.size(); i++) { + String pe = polymorphicEvents.get(i); + if (pe == null || pe.contains(".") || pe.startsWith(" parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i); String traced = variableTracer.traceLocalVariable(target, argName, parameterValues); if (traced != null && !traced.equals(argName)) { - String normalized = normalizeEnumConstantName(traced); + String normalized = resolveBoundArgumentToEnumConstant(traced, caller); if (normalized != null) { return normalized; } @@ -1994,7 +2417,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { for (CallEdge edge : edges) { if (edge.getTargetMethod().equals(target) || pathFinder.isHeuristicMatch(edge.getTargetMethod(), target)) { if (paramIndex < edge.getArguments().size()) { - String normalized = normalizeEnumConstantName(edge.getArguments().get(paramIndex)); + String normalized = resolveBoundArgumentToEnumConstant( + edge.getArguments().get(paramIndex), caller); if (normalized != null) { return normalized; } @@ -2007,6 +2431,62 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return null; } + private String resolveBoundArgumentToEnumConstant(String boundValue, String scopeMethodFqn) { + if (boundValue == null || boundValue.isBlank()) { + return null; + } + String fromLiteral = normalizeEnumConstantName(boundValue); + if (fromLiteral != null) { + return fromLiteral; + } + return resolveMethodCallExpressionToEnumConstant(boundValue, scopeMethodFqn); + } + + private String resolveMethodCallExpressionToEnumConstant(String expr, String scopeMethodFqn) { + if (expr == null || !expr.contains("(")) { + return null; + } + ASTNode node = parseExpressionString(expr); + while (node instanceof ParenthesizedExpression pe) { + node = pe.getExpression(); + } + if (!(node instanceof MethodInvocation mi) || !mi.arguments().isEmpty()) { + return null; + } + String methodName = mi.getName().getIdentifier(); + String receiverType = null; + Expression receiver = mi.getExpression(); + if (receiver instanceof SimpleName sn) { + receiverType = variableTracer.getVariableDeclaredType(scopeMethodFqn, sn.getIdentifier()); + } else if (receiver instanceof FieldAccess fa) { + receiverType = variableTracer.getVariableDeclaredType(scopeMethodFqn, fa.getName().getIdentifier()); + } + if (receiverType == null) { + return null; + } + String simpleReceiverType = receiverType.contains("<") + ? receiverType.substring(0, receiverType.indexOf('<')) + : receiverType; + TypeDeclaration td = context.getTypeDeclaration(simpleReceiverType); + if (td == null) { + td = context.getTypeDeclaration(receiverType); + } + if (td == null) { + return null; + } + String ownerFqn = context.getFqn(td); + CompilationUnit cu = td.getRoot() instanceof CompilationUnit contextCu ? contextCu : null; + List constants = constantExtractor.resolveMethodReturnConstant( + ownerFqn, methodName, 0, new HashSet<>(), cu); + if (constants.isEmpty()) { + return null; + } + if (constants.size() == 1) { + return normalizeEnumConstantName(constants.get(0)); + } + return null; + } + private String normalizeEnumConstantName(String value) { if (value == null || value.isBlank()) { return null; @@ -2141,6 +2621,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return false; } + private void extractEventConstantsFromConstructor(ClassInstanceCreation cic, List polymorphicEvents) { + if (cic.getAnonymousClassDeclaration() != null || cic.arguments().isEmpty()) { + return; + } + constantExtractor.extractConstantsFromExpression((Expression) cic.arguments().get(0), polymorphicEvents); + } + private void addConstantsFromTracedExpression( Expression expr, List constants, List path, Map> callGraph, String scopeMethod) { List traced = variableTracer.traceVariableAll(expr); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java index 4e1707e..3704898 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java @@ -147,19 +147,18 @@ public class CallGraphBuilder { } List polymorphicEvents = new ArrayList<>(); - if (resolvedValue.matches(".*\\.[a-zA-Z0-9_]+\\(\\)")) { - String varName = resolvedValue.substring(0, resolvedValue.indexOf('.')); - String methodName = resolvedValue.substring(resolvedValue.indexOf('.') + 1, resolvedValue.indexOf('(')); + if (!methodSuffix.isEmpty() && methodSuffix.matches("\\.[a-zA-Z0-9_]+\\(\\)") + && currentParamName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) { + String varName = currentParamName; + String methodName = methodSuffix.substring(1, methodSuffix.length() - 2); // Resolve in the first method in the path where the variable might be declared for (String methodFqn : path) { String declaredType = getVariableDeclaredType(methodFqn, varName); if (declaredType != null) { - System.out.println("DEEP TRACE: " + methodFqn + " " + varName + " -> " + declaredType); List typesToInspect = new ArrayList<>(); typesToInspect.add(declaredType); typesToInspect.addAll(context.getImplementations(declaredType)); - System.out.println("DEEP TRACE IMPLS: " + typesToInspect); for (String type : typesToInspect) { Set visited = new HashSet<>(); @@ -177,7 +176,6 @@ public class CallGraphBuilder { } } List constants = resolveMethodReturnConstant(type, methodName, 0, visited, cuToUse); - System.out.println("DEEP TRACE RETURN: " + type + " -> " + constants); for (String constant : constants) { if (!polymorphicEvents.contains(constant)) { polymorphicEvents.add(constant); @@ -265,7 +263,6 @@ public class CallGraphBuilder { List constants = new ArrayList<>(); TypeDeclaration td = contextCu != null ? context.getTypeDeclaration(className, contextCu) : context.getTypeDeclaration(className); if (td == null) { - System.out.println("DEEP TRACE FAILED TO FIND TD: " + className); } if (td != null) { MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); @@ -279,7 +276,6 @@ public class CallGraphBuilder { if (retExpr instanceof MethodInvocation mi) { // Follow delegation first String called = resolveCalledMethod(mi); - System.out.println("DEEP TRACE RESOLVED CALLED: " + called); if (called != null && called.contains(".")) { if (visited.contains(called)) { handled = true; @@ -549,9 +545,16 @@ public class CallGraphBuilder { } } - String val = constantResolver.resolve(expr, context); - if (val == null) { - val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc) + String val; + if (expr instanceof MethodInvocation mi && mi.getExpression() instanceof SimpleName + && mi.arguments().isEmpty()) { + // Keep local getter chains (e.g. event.getType()) for downstream parameter tracing. + val = expr.toString(); + } else { + val = constantResolver.resolve(expr, context); + if (val == null) { + val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc) + } } args.add(val); } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java index 77587e5..72cabc8 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java @@ -18,6 +18,12 @@ public class CallGraphPathFinder { this.context = context; } + public void clearAnalysisCaches() { + heuristicCache.clear(); + memoCache.clear(); + unreachableCache.clear(); + } + public List findPath(String start, String target, Map> graph, Set visited) { if (start.equals(target)) return new ArrayList<>(List.of(start)); if (!visited.add(start)) { @@ -360,19 +366,10 @@ public class CallGraphPathFinder { if (simpleClassNeighbor.equals(simpleClassTarget)) return true; - List impls = context.getImplementations(classTarget); - if (impls != null) { - for (String impl : impls) { - if (impl.equals(classNeighbor) || impl.endsWith("." + classNeighbor)) return true; - } + if (context.areClassesPolymorphicallyCompatible(classNeighbor, classTarget)) { + return true; } - List implsN = context.getImplementations(classNeighbor); - if (implsN != null) { - for (String impl : implsN) { - if (impl.equals(classTarget) || impl.endsWith("." + classTarget)) return true; - } - } - + return false; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java index b1b663e..b7b3cb8 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java @@ -294,6 +294,17 @@ public class ConstantExtractor { Set visited, CompilationUnit contextCu, ResolutionBudget budget) { + return resolveMethodReturnConstant(className, methodName, depth, visited, contextCu, budget, true); + } + + public List resolveMethodReturnConstant( + String className, + String methodName, + int depth, + Set visited, + CompilationUnit contextCu, + ResolutionBudget budget, + boolean allowWidenToImplementations) { log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName); log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth); if (budget == null) { @@ -316,9 +327,19 @@ public class ConstantExtractor { tempTd = context.getTypeDeclaration(className); } final TypeDeclaration td = tempTd; - boolean widenToImplementations = td == null + boolean widenToImplementations = allowWidenToImplementations + && (td == null || td.isInterface() - || Modifier.isAbstract(td.getModifiers()); + || Modifier.isAbstract(td.getModifiers())); + + if (widenToImplementations && depth == 0) { + Optional> precomputed = context.getPolymorphicAccessorIndex() + .widenedReturnConstants(className, methodName); + if (precomputed.isPresent()) { + visited.remove(fqn); + return new ArrayList<>(precomputed.get()); + } + } List constants = new ArrayList<>(); if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) { @@ -371,7 +392,7 @@ public class ConstantExtractor { if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) { List delegationResult = resolveMethodReturnConstant( - cName, mName, depth + 1, visited, contextCu, activeBudget); + cName, mName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations); constants.addAll(delegationResult); handled = true; } @@ -391,7 +412,7 @@ public class ConstantExtractor { if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) { List delegationResult = resolveMethodReturnConstant( - cName, mName, depth + 1, visited, contextCu, activeBudget); + cName, mName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations); constants.addAll(delegationResult); handled = true; } @@ -438,7 +459,7 @@ public class ConstantExtractor { if (impls != null) { for (String implName : impls) { List delegationResult = resolveMethodReturnConstant( - implName, methodName, depth + 1, visited, contextCu, activeBudget); + implName, methodName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations); for (String value : delegationResult) { if (!constants.contains(value)) { constants.add(value); @@ -472,6 +493,13 @@ public class ConstantExtractor { String requestingTypeFqn, CompilationUnit contextCu, Set visited) { + if (accessor != null && isSafeForPrecomputedFieldConstants(accessor)) { + Optional> precomputed = context.getIndexedFieldConstantsIndex() + .lookup(accessor.ownerFqn(), accessor.methodName()); + if (precomputed.isPresent()) { + return new ArrayList<>(precomputed.get()); + } + } return AccessorFieldResolver.resolveFieldConstants( accessor, requestingTypeFqn, @@ -508,9 +536,14 @@ public class ConstantExtractor { } } List implementations = context.getImplementations(className); - if (implementations == null) { + if (implementations == null || implementations.isEmpty()) { return Optional.empty(); } + Optional precomputed = context.getPolymorphicAccessorIndex() + .representativeImplAccessor(className, methodName); + if (precomputed.isPresent()) { + return precomputed; + } for (String implementation : implementations) { Optional implAccessor = context.getAccessorIndex().lookup(implementation, methodName); if (implAccessor.isPresent()) { @@ -551,6 +584,20 @@ public class ConstantExtractor { return constants; } + + private boolean isSafeForPrecomputedFieldConstants(AccessorSummary accessor) { + if (accessor.fieldName() == null || accessor.declaringFqn() == null) { + return accessor.kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER; + } + if (!accessor.ownerFqn().equals(accessor.declaringFqn())) { + return false; + } + TypeDeclaration ownerType = context.getTypeDeclaration(accessor.ownerFqn()); + return ownerType != null + && !ownerType.isInterface() + && !Modifier.isAbstract(ownerType.getModifiers()); + } + public String parseEnumSetElement(String eVal) { return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorAnalyzer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorAnalyzer.java index bcab312..5991fe1 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorAnalyzer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorAnalyzer.java @@ -14,6 +14,8 @@ public class ConstructorAnalyzer { private final ConstantResolver constantResolver; private VariableTracer variableTracer; private ConstantExtractor constantExtractor; + private final Map returnedCicCache = new HashMap<>(); + private final Set returnedCicAbsent = new HashSet<>(); @FunctionalInterface public interface RhsResolver { @@ -33,6 +35,11 @@ public class ConstructorAnalyzer { this.constantExtractor = constantExtractor; } + public void clearReturnedCicCache() { + returnedCicCache.clear(); + returnedCicAbsent.clear(); + } + public List traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set visited) { return traceFieldInConstructors(td, fieldName, context, visited, ResolutionBudget.defaults()); } @@ -480,7 +487,20 @@ public class ConstructorAnalyzer { public ClassInstanceCreation findReturnedCIC( String className, String methodName, CodebaseContext context) { - return findReturnedCIC(className, methodName, context, new HashSet<>(), 0); + String cacheKey = className + "#" + methodName; + if (returnedCicAbsent.contains(cacheKey)) { + return null; + } + if (returnedCicCache.containsKey(cacheKey)) { + return returnedCicCache.get(cacheKey); + } + ClassInstanceCreation resolved = findReturnedCIC(className, methodName, context, new HashSet<>(), 0); + if (resolved != null) { + returnedCicCache.put(cacheKey, resolved); + } else { + returnedCicAbsent.add(cacheKey); + } + return resolved; } private ClassInstanceCreation findReturnedCIC( diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngine.java index eef8111..e6bc835 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngine.java @@ -141,32 +141,6 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { return graph; } - @Override - protected String postProcessResolvedArgument(org.eclipse.jdt.core.dom.Expression originalExpr, String resolvedValue) { - // Preserve getter calls on 'this' or 'super' for later resolution in trigger parameter tracing - // where we can substitute the actual receiver from the call edge - boolean isThisOrSuperGetter = (originalExpr instanceof org.eclipse.jdt.core.dom.MethodInvocation mi - && (mi.getExpression() instanceof org.eclipse.jdt.core.dom.ThisExpression || mi.getExpression() instanceof org.eclipse.jdt.core.dom.SuperMethodInvocation) - && mi.arguments().isEmpty()) || (originalExpr instanceof org.eclipse.jdt.core.dom.SuperMethodInvocation smi && smi.arguments().isEmpty()); - - if (isThisOrSuperGetter) { - return originalExpr.toString(); // Preserve "this.getTransitionType()" or "super.getEvent()" for later substitution - } - - // Narrow resolution for "localVar.getX()" patterns where localVar is initialized - // from a ClassInstanceCreation (directly or via a factory method). This avoids - // the broad ENUM_SET fallback when the getter transforms one enum to another. - if (originalExpr instanceof org.eclipse.jdt.core.dom.MethodInvocation getterMi - && getterMi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName instanceSn - && getterMi.arguments().isEmpty()) { - String narrowed = tryNarrowGetterViaLocalVarChain(getterMi, instanceSn); - if (narrowed != null) { - return narrowed; - } - } - - return resolvedValue; - } private String findDefiningClass(String className, String methodName) { TypeDeclaration td = context.getTypeDeclaration(className); if (td == null) return null; @@ -230,351 +204,6 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { return null; } - protected String resolveReceiverTypeFallback(SimpleName receiverNameNode) { - String varName = receiverNameNode.getIdentifier(); - - CompilationUnit cu = receiverNameNode.getRoot() instanceof CompilationUnit ? (CompilationUnit) receiverNameNode.getRoot() : null; - TypeDeclaration staticTd = context.getTypeDeclaration(varName, cu); - if (staticTd != null) { - return context.getFqn(staticTd); - } - TypeDeclaration fallbackStaticTd = context.getTypeDeclaration(varName); - if (fallbackStaticTd != null) { - return context.getFqn(fallbackStaticTd); - } - - // 1. Check local variables in enclosing method - MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode); - if (enclosingMethod != null) { - // Check parameters - for (Object paramObj : enclosingMethod.parameters()) { - SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj; - if (svd.getName().getIdentifier().equals(varName)) { - return resolveTypeToFqn(svd.getType(), receiverNameNode); - } - } - // Check method body (local variables) - if (enclosingMethod.getBody() != null) { - Type[] foundType = new Type[1]; - enclosingMethod.getBody().accept(new ASTVisitor() { - @Override - public boolean visit(VariableDeclarationStatement node) { - for (Object fragObj : node.fragments()) { - VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; - if (frag.getName().getIdentifier().equals(varName)) { - foundType[0] = node.getType(); - } - } - return super.visit(node); - } - }); - if (foundType[0] != null) { - return resolveTypeToFqn(foundType[0], receiverNameNode); - } - } - } - - // 2. Check fields in enclosing class and its superclasses - TypeDeclaration enclosingType = findEnclosingType(receiverNameNode); - TypeDeclaration currentType = enclosingType; - while (currentType != null) { - for (FieldDeclaration field : currentType.getFields()) { - for (Object fragObj : field.fragments()) { - VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; - if (frag.getName().getIdentifier().equals(varName)) { - return resolveTypeToFqn(field.getType(), receiverNameNode); - } - } - } - String superclass = context.getSuperclassFqn(currentType); - currentType = superclass != null ? context.getTypeDeclaration(superclass) : null; - } - - return null; - } - - protected String resolveTypeToFqn(Type type, ASTNode contextNode) { - if (type == null) return null; - String simpleName; - if (type.isSimpleType()) { - simpleName = ((SimpleType) type).getName().getFullyQualifiedName(); - } else if (type.isParameterizedType()) { - return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode); - } else { - simpleName = type.toString(); - } - - CompilationUnit cu = (CompilationUnit) contextNode.getRoot(); - TypeDeclaration td = context.getTypeDeclaration(simpleName, cu); - if (td != null) { - return context.getFqn(td); - } - - // Fallback to import matching if CodebaseContext doesn't know it (e.g., external library) - for (Object impObj : cu.imports()) { - ImportDeclaration imp = (ImportDeclaration) impObj; - String impName = imp.getName().getFullyQualifiedName(); - if (impName.endsWith("." + simpleName)) { - return impName; - } - } - - return simpleName; - } - - /** - * Attempts to narrow the resolution of {@code instanceVar.getterMethod()} when the - * instance variable is initialised from a {@link org.eclipse.jdt.core.dom.ClassInstanceCreation} - * (either directly or via a factory/mapper method). - * - *

This handles both: - *

    - *
  • Simple pass-through: {@code Wrapper(MyEnum e){this.e=e;} getE(){return e;}}
  • - *
  • Transformation: {@code getSmEvent(){return switch(dtoEnum){case A->SmEvents.X;…};}}
  • - *
- * - * @return the resolved constant string, or {@code null} when narrowing is not possible - */ - private String tryNarrowGetterViaLocalVarChain( - MethodInvocation getterCall, SimpleName instanceSn) { - - MethodDeclaration enclosing = findEnclosingMethod(getterCall); - if (enclosing == null || enclosing.getBody() == null) return null; - - String varName = instanceSn.getIdentifier(); - String getterName = getterCall.getName().getIdentifier(); - - // Find the declared type and initialiser of the local variable - String[] varTypeName = {null}; - Expression[] initExpr = {null}; - enclosing.getBody().accept(new ASTVisitor() { - @Override - public boolean visit(VariableDeclarationStatement vds) { - for (Object fragObj : vds.fragments()) { - VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; - if (frag.getName().getIdentifier().equals(varName)) { - varTypeName[0] = vds.getType().toString(); - initExpr[0] = frag.getInitializer(); - } - } - return super.visit(vds); - } - }); - if (varTypeName[0] == null) return null; - - // Obtain the ClassInstanceCreation that created the object - org.eclipse.jdt.core.dom.ClassInstanceCreation cic = null; - String receiverType = null; - if (initExpr[0] instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation directCic) { - cic = directCic; - } else if (initExpr[0] instanceof MethodInvocation factoryMi - && (factoryMi.getExpression() instanceof SimpleName || factoryMi.getExpression() instanceof QualifiedName)) { - // e.g. "mapper.toMsg(dto)" or "com.example.Factory.toMsg()" - if (factoryMi.getExpression() instanceof SimpleName receiverSn) { - receiverType = resolveReceiverTypeFallback(receiverSn); - } else if (factoryMi.getExpression() instanceof QualifiedName qn) { - receiverType = qn.getFullyQualifiedName(); - } - if (receiverType != null) { - cic = findReturnedCIC(receiverType, factoryMi.getName().getIdentifier(), context); - } - } - if (cic == null) { - // Support builder pattern: e.g. EventWrapper.builder().event(TransitionEnum.STATE_X).build() - if (initExpr[0] instanceof MethodInvocation initMi) { - String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName; - Expression currentMi = initMi; - while (currentMi instanceof MethodInvocation chainMi) { - String mName = chainMi.getName().getIdentifier(); - if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) { - if (!chainMi.arguments().isEmpty()) { - return chainMi.arguments().get(0).toString(); - } - } - currentMi = chainMi.getExpression(); - } - } - return null; - } - - // Resolve the variable's declared type to a TypeDeclaration - CompilationUnit cu = (CompilationUnit) getterCall.getRoot(); - TypeDeclaration varTypeTd = context.getTypeDeclaration(varTypeName[0], cu); - if (varTypeTd == null) varTypeTd = context.getTypeDeclaration(varTypeName[0]); - if (varTypeTd == null) return null; - -// Evaluate the getter body with field values substituted from the CIC's constructor args - java.util.Map initialLocals = new java.util.HashMap<>(); - if (initExpr[0] instanceof MethodInvocation factoryMi) { - MethodDeclaration md = context.findMethodDeclaration(context.getTypeDeclaration(receiverType != null ? receiverType : varTypeName[0]), factoryMi.getName().getIdentifier(), true); - if (md != null) { - for (int i = 0; i < md.parameters().size() && i < factoryMi.arguments().size(); i++) { - SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i); - Expression arg = (Expression) factoryMi.arguments().get(i); - String resolvedVal = constantResolver.resolve(arg, context); - if (resolvedVal != null) { - initialLocals.put(param.getName().getIdentifier(), resolvedVal); - } - } - } - } - - java.util.Map fieldValues = constructorAnalyzer.buildFieldValuesFromCIC(varTypeTd, cic, context, this::evaluateMethodCallInConstructor, initialLocals); - if (fieldValues.isEmpty()) return null; - - // Track setter calls on the variable between its initialization and the getter call - // to capture field updates like e.setType("NEW_VALUE") - trackSetterCallsBetween(enclosing.getBody(), varName, getterCall, fieldValues, context); - - MethodDeclaration getter = context.findMethodDeclaration(varTypeTd, getterName, true); - if (getter == null || getter.getBody() == null) return null; - - return constantResolver.evaluateMethodBodyWithLocals( - getter, fieldValues, context, new java.util.HashSet<>()); - } - - /** - * Scans the method body for setter calls or field assignments to the given variable - * that occur between the variable's initialization and the getter call. - * Updates fieldValues with any field values set via setters. - */ - private void trackSetterCallsBetween(org.eclipse.jdt.core.dom.Block methodBody, String varName, - org.eclipse.jdt.core.dom.MethodInvocation getterCall, - java.util.Map fieldValues, - CodebaseContext context) { - java.util.List statements = new java.util.ArrayList<>(); - for (Object stmtObj : methodBody.statements()) { - statements.add((org.eclipse.jdt.core.dom.ASTNode) stmtObj); - } - - int varDeclIndex = -1; - int getterCallIndex = -1; - for (int i = 0; i < statements.size(); i++) { - org.eclipse.jdt.core.dom.ASTNode stmt = statements.get(i); - // Find variable declaration - if (varDeclIndex == -1) { - if (stmt instanceof org.eclipse.jdt.core.dom.VariableDeclarationStatement vds) { - for (Object fragObj : vds.fragments()) { - if (fragObj instanceof org.eclipse.jdt.core.dom.VariableDeclarationFragment frag - && frag.getName().getIdentifier().equals(varName)) { - varDeclIndex = i; - break; - } - } - } - } - // Find getter call - if (getterCallIndex == -1) { - if (stmt.toString().contains(getterCall.toString())) { - getterCallIndex = i; - break; - } - } - } - - if (varDeclIndex >= 0 && getterCallIndex > varDeclIndex) { - // Scan statements between var declaration and getter call - for (int i = varDeclIndex + 1; i < getterCallIndex; i++) { - org.eclipse.jdt.core.dom.ASTNode stmt = statements.get(i); - if (stmt instanceof org.eclipse.jdt.core.dom.ExpressionStatement es) { - org.eclipse.jdt.core.dom.Expression expr = es.getExpression(); - // Check for setter calls: var.setField(value) - if (expr instanceof org.eclipse.jdt.core.dom.MethodInvocation mi) { - if (mi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName sn - && sn.getIdentifier().equals(varName)) { - String methodName = mi.getName().getIdentifier(); - // Check if it's a setter (setXxx or xxx) - if (methodName.startsWith("set") && methodName.length() > 3) { - String fieldName = Character.toLowerCase(methodName.charAt(3)) - + methodName.substring(4); - if (!mi.arguments().isEmpty()) { - org.eclipse.jdt.core.dom.Expression arg = (org.eclipse.jdt.core.dom.Expression) mi.arguments().get(0); - String val = constantResolver.resolve(arg, context); - if (val != null) { - fieldValues.put(fieldName, val); - fieldValues.put("this." + fieldName, val); - } - } - } - } - } - // Check for direct field assignments: var.field = value - else if (expr instanceof org.eclipse.jdt.core.dom.Assignment assignment) { - if (assignment.getLeftHandSide() instanceof org.eclipse.jdt.core.dom.FieldAccess fa) { - if (fa.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName sn - && sn.getIdentifier().equals(varName)) { - String fieldName = fa.getName().getIdentifier(); - org.eclipse.jdt.core.dom.Expression rhs = assignment.getRightHandSide(); - String val = constantResolver.resolve(rhs, context); - if (val != null) { - fieldValues.put(fieldName, val); - fieldValues.put("this." + fieldName, val); - } - } - } - } - } - } - } - } - - /** - * Extracts a string representation of the receiver expression from a MethodInvocation. - * Handles ThisExpression, SimpleName, and other expression types. - */ - protected String getReceiverString(Expression receiver) { - if (receiver == null) { - return null; - } - if (receiver instanceof ThisExpression) { - return "this"; - } - if (receiver instanceof SuperMethodInvocation) { - return "super"; - } - if (receiver instanceof SimpleName sn) { - return sn.getIdentifier(); - } - return receiver.toString(); - } - - /** - * Finds the receiver expression used in the caller method when calling the target method. - * Walks the caller's AST to find a MethodInvocation with the target method name - * and returns the receiver string. - */ - protected String findCallerReceiver(String callerFqn, String targetMethodFqn) { - int lastDot = callerFqn.lastIndexOf('.'); - if (lastDot < 0) return null; - String callerClass = callerFqn.substring(0, lastDot); - String callerMethod = callerFqn.substring(lastDot + 1); - - int targetLastDot = targetMethodFqn.lastIndexOf('.'); - if (targetLastDot < 0) return null; - String targetMethodName = targetMethodFqn.substring(targetLastDot + 1); - - TypeDeclaration callerTd = context.getTypeDeclaration(callerClass); - if (callerTd == null) return null; - - MethodDeclaration callerMd = context.findMethodDeclaration(callerTd, callerMethod, false); - if (callerMd == null || callerMd.getBody() == null) return null; - - final String[] foundReceiver = {null}; - callerMd.getBody().accept(new ASTVisitor() { - @Override - public boolean visit(MethodInvocation node) { - if (foundReceiver[0] != null) return false; - String called = resolveCalledMethod(node); - if (called != null && (called.equals(targetMethodFqn) || called.endsWith("." + targetMethodName))) { - foundReceiver[0] = getReceiverString(node.getExpression()); - return false; - } - return super.visit(node); - } - }); - return foundReceiver[0]; - } - protected String resolveMethodInvocationReturnType(MethodInvocation mi) { Expression receiver = mi.getExpression(); String receiverType = null; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java index ad8eb26..270c2a9 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java @@ -46,7 +46,8 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { List args = resolveArguments(node.arguments()); String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node); for (String calledMethod : calledMethods) { - CallEdge edge = new CallEdge(calledMethod, args); + String receiver = getReceiverString(node.getExpression()); + CallEdge edge = new CallEdge(calledMethod, args, receiver); edge.setConstraint(resolveConstraint(node, calledMethod, constraint)); graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge); } @@ -64,7 +65,8 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { } else { implicitArgs.addAll(args); } - CallEdge edge = new CallEdge(refMethod, implicitArgs); + String receiver = getReceiverString(node.getExpression()); + CallEdge edge = new CallEdge(refMethod, implicitArgs, receiver); edge.setConstraint(constraint); graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge); } @@ -85,13 +87,14 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { } else { implicitArgs.addAll(args); } - CallEdge edge = new CallEdge(calledMethod, implicitArgs); + String receiver = getReceiverString(node.getExpression()); + CallEdge edge = new CallEdge(calledMethod, implicitArgs, receiver); edge.setConstraint(constraint); graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge); List impls = context.getImplementations(fallbackTypeFqn); for (String impl : impls) { - CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args); + CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args, receiver); implEdge.setConstraint(constraint); graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(implEdge); } @@ -125,8 +128,9 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { calledMethod = superFqn + "." + methodName; } List args = resolveArguments(node.arguments()); + String receiver = "super"; String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node); - CallEdge edge = new CallEdge(calledMethod, args); + CallEdge edge = new CallEdge(calledMethod, args, receiver); edge.setConstraint(constraint); graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge); } @@ -260,88 +264,6 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { return null; } - private String resolveReceiverTypeFallback(SimpleName receiverNameNode) { - String varName = receiverNameNode.getIdentifier(); - - // 1. Check local variables in enclosing method - MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode); - if (enclosingMethod != null) { - // Check parameters - for (Object paramObj : enclosingMethod.parameters()) { - SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj; - if (svd.getName().getIdentifier().equals(varName)) { - return resolveTypeToFqn(svd.getType(), receiverNameNode); - } - } - // Check method body (local variables) - if (enclosingMethod.getBody() != null) { - Type[] foundType = new Type[1]; - enclosingMethod.getBody().accept(new ASTVisitor() { - @Override - public boolean visit(VariableDeclarationStatement node) { - for (Object fragObj : node.fragments()) { - VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; - if (frag.getName().getIdentifier().equals(varName)) { - foundType[0] = node.getType(); - } - } - return super.visit(node); - } - }); - if (foundType[0] != null) { - return resolveTypeToFqn(foundType[0], receiverNameNode); - } - } - } - - // 2. Check fields in enclosing class and its superclasses - TypeDeclaration enclosingType = findEnclosingType(receiverNameNode); - TypeDeclaration currentType = enclosingType; - while (currentType != null) { - for (FieldDeclaration field : currentType.getFields()) { - for (Object fragObj : field.fragments()) { - VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; - if (frag.getName().getIdentifier().equals(varName)) { - return resolveTypeToFqn(field.getType(), receiverNameNode); - } - } - } - String superclass = context.getSuperclassFqn(currentType); - currentType = superclass != null ? context.getTypeDeclaration(superclass) : null; - } - - return null; - } - - private String resolveTypeToFqn(Type type, ASTNode contextNode) { - if (type == null) return null; - String simpleName; - if (type.isSimpleType()) { - simpleName = ((SimpleType) type).getName().getFullyQualifiedName(); - } else if (type.isParameterizedType()) { - return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode); - } else { - simpleName = type.toString(); - } - - CompilationUnit cu = (CompilationUnit) contextNode.getRoot(); - TypeDeclaration td = context.getTypeDeclaration(simpleName, cu); - if (td != null) { - return context.getFqn(td); - } - - // Fallback to import matching if CodebaseContext doesn't know it (e.g., external library) - for (Object impObj : cu.imports()) { - ImportDeclaration imp = (ImportDeclaration) impObj; - String impName = imp.getName().getFullyQualifiedName(); - if (impName.endsWith("." + simpleName)) { - return impName; - } - } - - return simpleName; - } - private Expression unwrapMessageBuilder(Expression expr) { if (expr instanceof MethodInvocation mi) { MethodInvocation current = mi; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtIntelligenceProvider.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtIntelligenceProvider.java index 63e9067..a80f7ba 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtIntelligenceProvider.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtIntelligenceProvider.java @@ -31,7 +31,6 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider { private final SpringComponentDetector componentDetector; private List cachedTriggers; private List cachedEntryPoints; - private List cachedCallChains; public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) { this.context = context; @@ -94,17 +93,15 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider { @Override public List findCallChains(List entryPoints, List triggers) { - if (cachedCallChains != null) { - return cachedCallChains; - } - List allEntryPoints = findEntryPoints(); - List allTriggers = triggers != null && !triggers.isEmpty() + List scopedEntryPoints = entryPoints != null && !entryPoints.isEmpty() + ? entryPoints + : findEntryPoints(); + List scopedTriggers = triggers != null && !triggers.isEmpty() ? triggers : findTriggerPoints(); log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers", - allEntryPoints.size(), allTriggers.size()); - cachedCallChains = callGraphEngine.findChains(allEntryPoints, allTriggers); - return cachedCallChains; + scopedEntryPoints.size(), scopedTriggers.size()); + return callGraphEngine.findChains(scopedEntryPoints, scopedTriggers); } @Override diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java index cfa842f..c4d4ce6 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java @@ -23,7 +23,7 @@ public class SpringMvcDetector { cu.accept(new ASTVisitor() { @Override public boolean visit(TypeDeclaration node) { - if (isRestController(node)) { + if (isRestController(node) && !node.isInterface()) { processController(node, entryPoints); } // Support WebFlux RouterFunction beans diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java index 73ccfdf..10dc335 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java @@ -7,6 +7,7 @@ import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel; import org.eclipse.jdt.core.dom.*; import java.util.*; @@ -16,6 +17,7 @@ public class VariableTracer { private ConstantExtractor constantExtractor; private final click.kamil.springstatemachineexporter.ast.common.DataFlowModel dataFlowModel; + private final Map variableDeclaredTypeCache = new HashMap<>(); public VariableTracer(CodebaseContext context, ConstantResolver constantResolver) { this.context = context; @@ -27,6 +29,13 @@ public class VariableTracer { this.constantExtractor = constantExtractor; } + public void clearAnalysisCaches() { + variableDeclaredTypeCache.clear(); + if (dataFlowModel instanceof JdtDataFlowModel jdtDataFlowModel) { + jdtDataFlowModel.clearAnalysisCaches(); + } + } + public Expression traceVariable(Expression expr) { List all = traceVariableAll(expr); return all.isEmpty() ? expr : all.get(all.size() - 1); @@ -159,9 +168,18 @@ public class VariableTracer { } private String getFieldType(TypeDeclaration td, String fieldName, CodebaseContext context, java.util.Set visited) { - if (td == null) return null; + if (td == null) { + return null; + } String typeFqn = context.getFqn(td); - if (!visited.add(typeFqn)) return null; + if (!visited.add(typeFqn)) { + return null; + } + + Optional indexed = context.getFieldTypeIndex().lookup(typeFqn, fieldName); + if (indexed.isPresent()) { + return indexed.get(); + } for (FieldDeclaration fd : td.getFields()) { for (Object fragObj : fd.fragments()) { @@ -215,7 +233,21 @@ public class VariableTracer { } public String getVariableDeclaredType(String methodFqn, String cleanFieldName) { - if (methodFqn == null) return null; + if (methodFqn == null) { + return null; + } + String cacheKey = methodFqn + "|" + cleanFieldName; + String cached = variableDeclaredTypeCache.get(cacheKey); + if (cached != null) { + return cached.isEmpty() ? null : cached; + } + + String resolved = resolveVariableDeclaredTypeUncached(methodFqn, cleanFieldName); + variableDeclaredTypeCache.put(cacheKey, resolved == null ? "" : resolved); + return resolved; + } + + private String resolveVariableDeclaredTypeUncached(String methodFqn, String cleanFieldName) { int lastDot = methodFqn.lastIndexOf('.'); if (lastDot > 0) { String fqn = methodFqn.substring(0, lastDot); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java index ac9ac17..761ba95 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java @@ -2,6 +2,17 @@ package click.kamil.springstatemachineexporter.ast.common; import click.kamil.springstatemachineexporter.analysis.index.AccessorIndex; import click.kamil.springstatemachineexporter.analysis.index.AccessorIndexBuilder; +import click.kamil.springstatemachineexporter.analysis.index.FieldTypeIndex; +import click.kamil.springstatemachineexporter.analysis.index.FieldTypeIndexBuilder; +import click.kamil.springstatemachineexporter.analysis.index.GetterChainIndex; +import click.kamil.springstatemachineexporter.analysis.index.GetterChainIndexBuilder; +import click.kamil.springstatemachineexporter.analysis.index.IndexedFieldConstantsIndex; +import click.kamil.springstatemachineexporter.analysis.index.IndexedFieldConstantsIndexBuilder; +import click.kamil.springstatemachineexporter.analysis.index.PolymorphicAccessorIndex; +import click.kamil.springstatemachineexporter.analysis.index.PolymorphicAccessorIndexBuilder; +import click.kamil.springstatemachineexporter.analysis.index.PolymorphicMethodExpansionIndex; +import click.kamil.springstatemachineexporter.analysis.index.PolymorphicMethodExpansionIndexBuilder; +import click.kamil.springstatemachineexporter.analysis.pipeline.ClassCompatibilityCache; import click.kamil.springstatemachineexporter.analysis.model.LibraryHint; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver; @@ -29,6 +40,7 @@ import lombok.extern.slf4j.Slf4j; @Slf4j public class CodebaseContext { private final Map classes = new HashMap<>(); + private final Map typeDeclarations = new HashMap<>(); private final Map recordDeclarations = new HashMap<>(); private final Map classPaths = new HashMap<>(); private final Map simpleNameToFqn = new HashMap<>(); @@ -47,6 +59,16 @@ public class CodebaseContext { private Path projectRoot; private final Map cache = new HashMap<>(); private AccessorIndex accessorIndex = AccessorIndex.empty(); + private PolymorphicAccessorIndex polymorphicAccessorIndex = PolymorphicAccessorIndex.empty(); + private GetterChainIndex getterChainIndex = GetterChainIndex.empty(); + private IndexedFieldConstantsIndex indexedFieldConstantsIndex = IndexedFieldConstantsIndex.empty(); + private FieldTypeIndex fieldTypeIndex = FieldTypeIndex.empty(); + private PolymorphicMethodExpansionIndex polymorphicMethodExpansionIndex = + PolymorphicMethodExpansionIndex.empty(); + private final ClassCompatibilityCache classCompatibilityCache = new ClassCompatibilityCache(); + private final Map methodDeclarationCache = new HashMap<>(); + private final Map superclassFqnCache = new HashMap<>(); + private final Map> implementationsCache = new HashMap<>(); private boolean inlineAccessors = true; public Map getCache() { @@ -57,6 +79,40 @@ public class CodebaseContext { return accessorIndex; } + public PolymorphicAccessorIndex getPolymorphicAccessorIndex() { + return polymorphicAccessorIndex; + } + + public GetterChainIndex getGetterChainIndex() { + return getterChainIndex; + } + + public IndexedFieldConstantsIndex getIndexedFieldConstantsIndex() { + return indexedFieldConstantsIndex; + } + + public FieldTypeIndex getFieldTypeIndex() { + return fieldTypeIndex; + } + + public PolymorphicMethodExpansionIndex getPolymorphicMethodExpansionIndex() { + return polymorphicMethodExpansionIndex; + } + + public ClassCompatibilityCache getClassCompatibilityCache() { + return classCompatibilityCache; + } + + public void clearAnalysisCaches() { + methodDeclarationCache.clear(); + superclassFqnCache.clear(); + classCompatibilityCache.clear(); + } + + public Set getIndexedTypeFqns() { + return Collections.unmodifiableSet(classes.keySet()); + } + public boolean isInlineAccessors() { return inlineAccessors; } @@ -241,6 +297,7 @@ public class CodebaseContext { List javaFiles = FileUtils.findFilesWithExtension(rootDirs, ".java", activeIgnore); log.info("CodebaseContext found {} java files across {} root directories", javaFiles.size(), rootDirs.size()); + typeDeclarations.clear(); for (Path javaFile : javaFiles) { String source = Files.readString(javaFile); CompilationUnit cu = parse(source, javaFile.toAbsolutePath().toString()); @@ -258,9 +315,22 @@ public class CodebaseContext { } } + this.implementationsCache.clear(); this.accessorIndex = inlineAccessors ? new AccessorIndexBuilder().build(this) : AccessorIndex.empty(); + this.polymorphicAccessorIndex = inlineAccessors + ? new PolymorphicAccessorIndexBuilder().build(this, accessorIndex) + : PolymorphicAccessorIndex.empty(); + this.getterChainIndex = inlineAccessors + ? new GetterChainIndexBuilder().build(this, accessorIndex) + : GetterChainIndex.empty(); + this.indexedFieldConstantsIndex = inlineAccessors + ? new IndexedFieldConstantsIndexBuilder().build(this, accessorIndex) + : IndexedFieldConstantsIndex.empty(); + this.fieldTypeIndex = new FieldTypeIndexBuilder().build(this); + this.polymorphicMethodExpansionIndex = new PolymorphicMethodExpansionIndexBuilder().build(this); + clearAnalysisCaches(); if (inlineAccessors) { log.info("Built accessor index: {} trivial accessors across {} types", accessorIndex.trivialCount(), accessorIndex.typeCount()); @@ -275,6 +345,7 @@ public class CodebaseContext { classes.put(fqn, (CompilationUnit) td.getRoot()); classPaths.put(fqn, javaFile); + typeDeclarations.put(fqn, td); if (simpleNameToFqn.containsKey(simpleName)) { String existingFqn = simpleNameToFqn.get(simpleName); @@ -343,15 +414,21 @@ public class CodebaseContext { } public List getImplementations(String interfaceName) { - String cleanName = interfaceName; - if (interfaceName != null && interfaceName.contains("<")) { - cleanName = interfaceName.substring(0, interfaceName.indexOf('<')); + if (interfaceName == null) { + return List.of(); } + String cleanName = interfaceName.contains("<") + ? interfaceName.substring(0, interfaceName.indexOf('<')) + : interfaceName; + return implementationsCache.computeIfAbsent(cleanName, this::computeImplementations); + } + + private List computeImplementations(String cleanName) { Set allImpls = new LinkedHashSet<>(); collectImplementations(cleanName, allImpls, new HashSet<>()); List sorted = new ArrayList<>(allImpls); Collections.sort(sorted); - return sorted; + return Collections.unmodifiableList(sorted); } private void collectImplementations(String typeName, Set results, Set visited) { @@ -465,10 +542,20 @@ public class CodebaseContext { } public TypeDeclaration getTypeDeclaration(String name) { + TypeDeclaration cached = typeDeclarations.get(name); + if (cached != null) { + return cached; + } + // Try exact FQN match first CompilationUnit cu = classes.get(name); - if (cu != null) - return findTypeInCu(cu, name); + if (cu != null) { + TypeDeclaration resolved = findTypeInCu(cu, name); + if (resolved != null) { + typeDeclarations.put(name, resolved); + } + return resolved; + } // If it's a simple name, check if it's ambiguous if (ambiguousSimpleNames.contains(name)) { @@ -479,9 +566,18 @@ public class CodebaseContext { // If not ambiguous, return the one we found during scan String fqn = simpleNameToFqn.get(name); if (fqn != null) { + cached = typeDeclarations.get(fqn); + if (cached != null) { + return cached; + } cu = classes.get(fqn); - if (cu != null) - return findTypeInCu(cu, fqn); + if (cu != null) { + TypeDeclaration resolved = findTypeInCu(cu, fqn); + if (resolved != null) { + typeDeclarations.put(fqn, resolved); + } + return resolved; + } } return null; @@ -541,7 +637,21 @@ public class CodebaseContext { } public String getSuperclassFqn(TypeDeclaration td) { - if (td == null) return null; + if (td == null) { + return null; + } + String typeFqn = getFqn(td); + if (typeFqn != null && superclassFqnCache.containsKey(typeFqn)) { + return superclassFqnCache.get(typeFqn); + } + String resolved = resolveSuperclassFqn(td); + if (typeFqn != null) { + superclassFqnCache.put(typeFqn, resolved); + } + return resolved; + } + + private String resolveSuperclassFqn(TypeDeclaration td) { ITypeBinding binding = td.resolveBinding(); if (binding != null) { ITypeBinding superBinding = binding.getSuperclass(); @@ -571,7 +681,23 @@ public class CodebaseContext { } public MethodDeclaration findMethodDeclaration(TypeDeclaration td, String methodName, boolean includeSuper) { - if (td == null) return null; + if (td == null) { + return null; + } + String typeFqn = getFqn(td); + if (typeFqn != null) { + String cacheKey = typeFqn + "#" + methodName + "#" + includeSuper; + if (methodDeclarationCache.containsKey(cacheKey)) { + return methodDeclarationCache.get(cacheKey); + } + MethodDeclaration resolved = findMethodDeclarationUncached(td, methodName, includeSuper); + methodDeclarationCache.put(cacheKey, resolved); + return resolved; + } + return findMethodDeclarationUncached(td, methodName, includeSuper); + } + + private MethodDeclaration findMethodDeclarationUncached(TypeDeclaration td, String methodName, boolean includeSuper) { ITypeBinding binding = td.resolveBinding(); if (binding != null && includeSuper) { IMethodBinding methodBinding = findMethodBinding(binding, methodName); @@ -650,6 +776,44 @@ public class CodebaseContext { return null; } + public boolean areClassesPolymorphicallyCompatible(String classNeighbor, String classTarget) { + if (classNeighbor == null || classTarget == null) { + return false; + } + if (classNeighbor.equals(classTarget)) { + return true; + } + String simpleClassNeighbor = simpleTypeName(classNeighbor); + String simpleClassTarget = simpleTypeName(classTarget); + if (simpleClassNeighbor.equals(simpleClassTarget)) { + return true; + } + return classCompatibilityCache.areClassesCompatible( + classNeighbor, + classTarget, + this::computePolymorphicClassCompatibility); + } + + private boolean computePolymorphicClassCompatibility(String classNeighbor, String classTarget) { + List targetImplementations = getImplementations(classTarget); + for (String implementation : targetImplementations) { + if (implementation.equals(classNeighbor) || implementation.endsWith("." + classNeighbor)) { + return true; + } + } + List neighborImplementations = getImplementations(classNeighbor); + for (String implementation : neighborImplementations) { + if (implementation.equals(classTarget) || implementation.endsWith("." + classTarget)) { + return true; + } + } + return false; + } + + private static String simpleTypeName(String typeFqn) { + return typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn; + } + public org.eclipse.jdt.core.dom.RecordDeclaration getRecordDeclaration(String fqn) { if (fqn == null || fqn.isEmpty()) { return null; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java index 35538bd..faa8931 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java @@ -14,6 +14,7 @@ public class JdtDataFlowModel implements DataFlowModel { private final Map cfgCache = new HashMap<>(); private final Map rdCache = new HashMap<>(); private final Map> objectFieldBindings = new HashMap<>(); + private final Map> compatibleContextTypesCache = new HashMap<>(); private static final ThreadLocal> CURRENT_PATH = new ThreadLocal<>(); @@ -33,6 +34,10 @@ public class JdtDataFlowModel implements DataFlowModel { this.context = context; } + public void clearAnalysisCaches() { + compatibleContextTypesCache.clear(); + } + private MethodDeclaration findEnclosingMethod(ASTNode node) { ASTNode current = node; while (current != null) { @@ -1514,6 +1519,12 @@ public class JdtDataFlowModel implements DataFlowModel { } private Set getCompatibleContextTypes(List currentPath, String declaringClassFqn) { + String cacheKey = String.valueOf(currentPath) + "#" + declaringClassFqn; + return compatibleContextTypesCache.computeIfAbsent( + cacheKey, ignored -> computeCompatibleContextTypes(currentPath, declaringClassFqn)); + } + + private Set computeCompatibleContextTypes(List currentPath, String declaringClassFqn) { Set contextTypes = new HashSet<>(); List implementations = context.getImplementations(declaringClassFqn); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilterEntryPointTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilterEntryPointTest.java index 1d9a3d0..347097e 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilterEntryPointTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilterEntryPointTest.java @@ -45,4 +45,44 @@ class MachineScopeFilterEntryPointTest { .contains("POST /api/orders/pay", "POST /api/commands/{commandKey}") .doesNotContain("POST /api/documents/submit"); } + + @Test + void shouldKeepDedicatedOrderEndpointOnStandardOrderMachineInMultiModuleCodebase() { + EntryPoint orderPay = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/orders/pay") + .className("click.kamil.examples.statemachine.layered.web.OrderController") + .methodName("pay") + .metadata(Map.of("path", "/api/orders/pay")) + .build(); + + CodebaseContext context = new CodebaseContext(); + List scoped = MachineScopeFilter.filterEntryPointsForMachine( + List.of(orderPay), + "click.kamil.examples.statemachine.layered.order.config.StandardOrderStateMachineConfiguration", + context); + + assertThat(scoped).extracting(EntryPoint::getName) + .containsExactly("POST /api/orders/pay"); + } + + @Test + void shouldKeepOrdersPathOnNonDomainNamedMachineConfig() { + EntryPoint ordersSubmit = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/v2/orders/submit") + .className("click.kamil.examples.statemachine.inheritance.api.OrderControllerImpl") + .methodName("submitOrder") + .metadata(Map.of("path", "/api/v2/orders/submit")) + .build(); + + CodebaseContext context = new CodebaseContext(); + List scoped = MachineScopeFilter.filterEntryPointsForMachine( + List.of(ordersSubmit), + "click.kamil.examples.statemachine.inheritance.config.InheritanceStateMachineConfig", + context); + + assertThat(scoped).extracting(EntryPoint::getName) + .containsExactly("POST /api/v2/orders/submit"); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngineTest.java index b57478e..a48b477 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngineTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngineTest.java @@ -1,6 +1,7 @@ package click.kamil.springstatemachineexporter.analysis.enricher.routing; import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -9,6 +10,7 @@ import org.junit.jupiter.api.io.TempDir; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; @@ -165,4 +167,37 @@ public class HeuristicBeanResolutionEngineTest { engine.isRoutedToCorrectMachine(chain, "com.example.DocumentStateMachineConfig", context), "Ambiguous shared gateway should not attach to every machine"); } + + @Test + void shouldAcceptTriggerWhenSpringErasureDiffersFromStringMachineTypes(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("InheritanceStateMachineConfig.java"), """ + package com.example; + @org.springframework.statemachine.config.EnableStateMachine + public class InheritanceStateMachineConfig + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + """); + Files.writeString(tempDir.resolve("ProcessingServiceImpl.java"), """ + package com.example; + public class ProcessingServiceImpl { + public void doProcess() {} + } + """); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallChain triggerProbe = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .className("com.example.ProcessingServiceImpl") + .methodName("doProcess") + .event("INHERITED_SUBMIT") + .stateTypeFqn("java.lang.Object") + .eventTypeFqn("java.lang.Object") + .build()) + .methodChain(List.of()) + .build(); + + assertTrue(engine.isRoutedToCorrectMachine( + triggerProbe, "com.example.InheritanceStateMachineConfig", context)); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AnalysisPerformanceCacheTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AnalysisPerformanceCacheTest.java new file mode 100644 index 0000000..935ab62 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AnalysisPerformanceCacheTest.java @@ -0,0 +1,108 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer; +import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AnalysisPerformanceCacheTest { + + @TempDir + Path tempDir; + + CodebaseContext context; + ConstantExtractor constantExtractor; + + @BeforeEach + void setUp() throws IOException { + context = new CodebaseContext(); + context.setResolveBindings(true); + context.setSourcepath(List.of(tempDir.toAbsolutePath().toString())); + ConstantResolver constantResolver = context.getConstantResolver(); + constantExtractor = new ConstantExtractor(context, constantResolver, mi -> null); + constantExtractor.setConstructorAnalyzer(new ConstructorAnalyzer(context, constantResolver)); + } + + @Test + void shouldPrecomputeIndexedFieldConstants() throws IOException { + writeJava("com/example/Payload.java", """ + package com.example; + public class Payload { + private String event = "PAY"; + public String getEvent() { return event; } + } + """); + scan(); + + List precomputed = context.getIndexedFieldConstantsIndex() + .lookup("com.example.Payload", "getEvent") + .orElseThrow(); + assertThat(precomputed).containsExactly("PAY"); + + List first = constantExtractor.resolveMethodReturnConstant( + "com.example.Payload", "getEvent", 0, new HashSet<>(), null); + List second = constantExtractor.resolveMethodReturnConstant( + "com.example.Payload", "getEvent", 0, new HashSet<>(), null); + assertThat(first).containsExactly("PAY"); + assertThat(second).isEqualTo(first); + } + + @Test + void findMethodDeclarationShouldBeMemoized() throws IOException { + writeJava("com/example/Demo.java", """ + package com.example; + public class Demo { + public void run() {} + } + """); + scan(); + + TypeDeclaration type = context.getTypeDeclaration("com.example.Demo"); + MethodDeclaration first = context.findMethodDeclaration(type, "run", true); + MethodDeclaration second = context.findMethodDeclaration(type, "run", true); + + assertThat(first).isNotNull(); + assertThat(second).isSameAs(first); + } + + @Test + void shouldMemoizePolymorphicClassCompatibility() throws IOException { + writeJava("com/example/EventSource.java", """ + package com.example; + public interface EventSource {} + """); + writeJava("com/example/PayEvent.java", """ + package com.example; + public class PayEvent implements EventSource {} + """); + scan(); + + assertThat(context.areClassesPolymorphicallyCompatible( + "com.example.PayEvent", "com.example.EventSource")).isTrue(); + assertThat(context.areClassesPolymorphicallyCompatible( + "com.example.PayEvent", "com.example.EventSource")).isTrue(); + } + + private void scan() throws IOException { + context.scan(tempDir); + } + + private void writeJava(String relativePath, String source) throws IOException { + Path file = tempDir.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, source); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainIndexTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainIndexTest.java new file mode 100644 index 0000000..ac2532d --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/GetterChainIndexTest.java @@ -0,0 +1,97 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver; +import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget; +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer; +import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class GetterChainIndexTest { + + @TempDir + Path tempDir; + + CodebaseContext context; + AccessorResolver accessorResolver; + + @BeforeEach + void setUp() throws IOException { + context = new CodebaseContext(); + context.setResolveBindings(true); + context.setSourcepath(List.of(tempDir.toAbsolutePath().toString())); + ConstantResolver constantResolver = context.getConstantResolver(); + ConstantExtractor constantExtractor = new ConstantExtractor(context, constantResolver, mi -> null); + ConstructorAnalyzer constructorAnalyzer = new ConstructorAnalyzer(context, constantResolver); + accessorResolver = new AccessorResolver(context, constantExtractor, constructorAnalyzer, constantResolver); + } + + @Test + void shouldPrecomputeGetterChainEdgeWithFieldInitializerCic() throws IOException { + writeJava("com/example/Payload.java", """ + package com.example; + public class Payload { + public String getType() { return "PAY"; } + } + """); + writeJava("com/example/Inner.java", """ + package com.example; + public class Inner { + private final Payload payload = new Payload(); + public Payload getPayload() { return payload; } + } + """); + scan(); + + GetterChainIndex index = context.getGetterChainIndex(); + assertThat(index.edgeCount()).isGreaterThan(0); + + GetterChainEdge edge = index.lookup("com.example.Inner", "getPayload").orElseThrow(); + assertThat(edge.nextTypeFqn()).contains("Payload"); + assertThat(edge.fieldInitializerCic()).isNotNull(); + + AccessorResolver.GetterChainStep step = accessorResolver.resolveGetterChainStep( + "com.example.Inner", "getPayload", ResolutionBudget.defaults()); + assertThat(step).isNotNull(); + assertThat(step.typeFqn()).isEqualTo(edge.nextTypeFqn()); + assertThat(step.cic()).isSameAs(edge.fieldInitializerCic()); + } + + @Test + void getTypeDeclarationShouldUseFlatIndex() throws IOException { + writeJava("com/example/Demo.java", """ + package com.example; + public class Demo { + public String getValue() { return "X"; } + } + """); + scan(); + + TypeDeclaration first = context.getTypeDeclaration("com.example.Demo"); + TypeDeclaration second = context.getTypeDeclaration("com.example.Demo"); + + assertThat(first).isNotNull(); + assertThat(second).isSameAs(first); + } + + private void scan() throws IOException { + context.scan(tempDir); + } + + private void writeJava(String relativePath, String source) throws IOException { + Path file = tempDir.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, source); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorIndexTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorIndexTest.java new file mode 100644 index 0000000..9c883c4 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/PolymorphicAccessorIndexTest.java @@ -0,0 +1,238 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer; +import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PolymorphicAccessorIndexTest { + + @TempDir + Path tempDir; + + CodebaseContext context; + ConstantExtractor constantExtractor; + + @BeforeEach + void setUp() { + context = new CodebaseContext(); + constantExtractor = new ConstantExtractor( + context, + new ConstantResolver(), + mi -> null); + constantExtractor.setConstructorAnalyzer(new ConstructorAnalyzer(context, context.getConstantResolver())); + } + + @Test + void shouldPrecomputeWidenedConstantsForThreeEventImplementations() throws IOException { + writeJava("com/example/EventSource.java", """ + package com.example; + public interface EventSource { + String getEvent(); + } + """); + writeJava("com/example/PayEvent.java", """ + package com.example; + public class PayEvent implements EventSource { + public String getEvent() { return "PAY"; } + } + """); + writeJava("com/example/ShipEvent.java", """ + package com.example; + public class ShipEvent implements EventSource { + public String getEvent() { return "SHIP"; } + } + """); + writeJava("com/example/CancelEvent.java", """ + package com.example; + public class CancelEvent implements EventSource { + public String getEvent() { return "CANCEL"; } + } + """); + scan(); + + PolymorphicAccessorIndex index = context.getPolymorphicAccessorIndex(); + assertThat(index.entryCount()).isGreaterThan(0); + + List precomputed = index.widenedReturnConstants("com.example.EventSource", "getEvent") + .orElseThrow(); + assertThat(precomputed).containsExactlyInAnyOrder("PAY", "SHIP", "CANCEL"); + + List resolved = constantExtractor.resolveMethodReturnConstant( + "com.example.EventSource", "getEvent", 0, new HashSet<>(), null); + assertThat(resolved).containsExactlyInAnyOrder("PAY", "SHIP", "CANCEL"); + } + + @Test + void shouldIncludeDefaultMethodAndConcreteOverrides() throws IOException { + writeJava("com/example/EventSource.java", """ + package com.example; + public interface EventSource { + default String getEvent() { return "DEFAULT"; } + } + """); + writeJava("com/example/PayEvent.java", """ + package com.example; + public class PayEvent implements EventSource { + public String getEvent() { return "PAY"; } + } + """); + writeJava("com/example/ShipEvent.java", """ + package com.example; + public class ShipEvent implements EventSource { + public String getEvent() { return "SHIP"; } + } + """); + writeJava("com/example/CancelEvent.java", """ + package com.example; + public class CancelEvent implements EventSource { + public String getEvent() { return "CANCEL"; } + } + """); + scan(); + + List precomputed = context.getPolymorphicAccessorIndex() + .widenedReturnConstants("com.example.EventSource", "getEvent") + .orElseThrow(); + assertThat(precomputed).containsExactlyInAnyOrder("DEFAULT", "PAY", "SHIP", "CANCEL"); + } + + @Nested + class AbstractStateMachineInheritance { + + @Test + void shouldResolveEventsFromThreeConcreteConfigsExtendingAbstractBase() throws IOException { + writeJava("com/example/OrderState.java", """ + package com.example; + public enum OrderState { CREATED, PAID, SHIPPED, CANCELLED } + """); + writeJava("com/example/OrderEvent.java", """ + package com.example; + public enum OrderEvent { PAY, SHIP, CANCEL } + """); + writeJava("com/example/EventSource.java", """ + package com.example; + public interface EventSource { + OrderEvent getEvent(); + } + """); + writeJava("com/example/PayEvent.java", """ + package com.example; + public class PayEvent implements EventSource { + public OrderEvent getEvent() { return OrderEvent.PAY; } + } + """); + writeJava("com/example/ShipEvent.java", """ + package com.example; + public class ShipEvent implements EventSource { + public OrderEvent getEvent() { return OrderEvent.SHIP; } + } + """); + writeJava("com/example/CancelEvent.java", """ + package com.example; + public class CancelEvent implements EventSource { + public OrderEvent getEvent() { return OrderEvent.CANCEL; } + } + """); + writeJava("com/example/BaseOrderStateMachineConfig.java", """ + package com.example; + import org.springframework.statemachine.config.StateMachineConfigurerAdapter; + import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer; + public abstract class BaseOrderStateMachineConfig + extends StateMachineConfigurerAdapter { + protected void registerTransition( + StateMachineTransitionConfigurer transitions, + EventSource source) { + transitions.withExternal() + .source(OrderState.CREATED) + .target(OrderState.PAID) + .event(source.getEvent()); + } + } + """); + writeJava("com/example/PayOrderStateMachineConfig.java", """ + package com.example; + import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer; + public class PayOrderStateMachineConfig extends BaseOrderStateMachineConfig { + @Override + public void configure(StateMachineTransitionConfigurer transitions) + throws Exception { + registerTransition(transitions, new PayEvent()); + } + } + """); + writeJava("com/example/ShipOrderStateMachineConfig.java", """ + package com.example; + import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer; + public class ShipOrderStateMachineConfig extends BaseOrderStateMachineConfig { + @Override + public void configure(StateMachineTransitionConfigurer transitions) + throws Exception { + registerTransition(transitions, new ShipEvent()); + } + } + """); + writeJava("com/example/CancelOrderStateMachineConfig.java", """ + package com.example; + import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer; + public class CancelOrderStateMachineConfig extends BaseOrderStateMachineConfig { + @Override + public void configure(StateMachineTransitionConfigurer transitions) + throws Exception { + registerTransition(transitions, new CancelEvent()); + } + } + """); + scan(); + + List widened = context.getPolymorphicAccessorIndex() + .widenedReturnConstants("com.example.EventSource", "getEvent") + .orElseThrow(); + assertThat(widened).containsExactlyInAnyOrder("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"); + + List resolved = constantExtractor.resolveMethodReturnConstant( + "com.example.EventSource", "getEvent", 0, new HashSet<>(), null); + assertThat(resolved).containsExactlyInAnyOrder("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"); + } + } + + @Test + void getImplementationsShouldBeMemoized() throws IOException { + writeJava("com/example/EventSource.java", """ + package com.example; + public interface EventSource {} + """); + writeJava("com/example/PayEvent.java", """ + package com.example; + public class PayEvent implements EventSource {} + """); + scan(); + + List first = context.getImplementations("com.example.EventSource"); + List second = context.getImplementations("com.example.EventSource"); + + assertThat(second).isSameAs(first); + } + + private void scan() throws IOException { + context.scan(tempDir); + } + + private void writeJava(String relativePath, String source) throws IOException { + Path file = tempDir.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, source); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisCacheCorrectnessTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisCacheCorrectnessTest.java index 27abb52..8291805 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisCacheCorrectnessTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisCacheCorrectnessTest.java @@ -97,7 +97,7 @@ class AnalysisCacheCorrectnessTest { List first = intelligence.findCallChains(entryPoints, triggers); List second = intelligence.findCallChains(entryPoints, triggers); - assertThat(second).isSameAs(first); + assertThat(second).isEqualTo(first); } /** diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java index 210f743..3d33ea2 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java @@ -20,8 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat; *

Each test embeds a miniature codebase as a string so humans and agents can see the full * REST → dispatcher → sendEvent pipeline without opening another module. * - *

DTO setter/getter propagation is covered by {@code HeuristicCallGraphEngineTypeTest}. - * Cross-hop routing helpers that return computed strings are covered by literal-forwarding tests above. + *

Cross-hop routing helpers and DTO constructor→getter propagation are covered by Tier 1 tests below. */ class CentralDispatcherResolutionTest { @@ -418,6 +417,105 @@ class CentralDispatcherResolutionTest { "OrderEvent.PAY"); } + /** + * Tier 1 — field-injected routing helper returns string literals consumed by central dispatcher. + * + *

+     * pay()  → gateway.routeAndPay()  → dispatch("ORDER", routingHelper.payAction())  → OrderEvent.PAY
+     * ship() → gateway.routeAndShip() → dispatch("ORDER", routingHelper.shipAction()) → OrderEvent.SHIP
+     * 
+ */ + @Test + void fieldWiredRoutingHelperShouldForwardLiteralsToCentralDispatcher(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + CommandGateway gateway; + public void pay() { gateway.routeAndPay(); } + public void ship() { gateway.routeAndShip(); } + } + class CommandGateway { + OrderRoutingHelper routingHelper; + CentralDispatcher dispatcher; + void routeAndPay() { dispatcher.dispatch("ORDER", routingHelper.payAction()); } + void routeAndShip() { dispatcher.dispatch("ORDER", routingHelper.shipAction()); } + } + class OrderRoutingHelper { + String payAction() { return "PAY"; } + String shipAction() { return "SHIP"; } + } + class CentralDispatcher { + void dispatch(String domain, String action) { + StateMachine sm = new StateMachine(); + sm.sendEvent(OrderEvent.valueOf(action)); + } + } + enum OrderEvent { PAY, SHIP } + class StateMachine { public void sendEvent(OrderEvent e) {} } + class OrderStateMachineConfig {} + """; + CodebaseContext context = scanSource(source, tempDir); + + CallChain payChain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE); + CallChain shipChain = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE); + + assertPolyEvents(payChain, "OrderEvent.PAY"); + assertPolyEvents(shipChain, "OrderEvent.SHIP"); + + assertLinkedEvent( + linkEndpointToTransition(payChain, MACHINE_CONFIG, + transition("NEW", "PAID", "OrderEvent.PAY"), + transition("PAID", "SHIPPED", "OrderEvent.SHIP")), + "OrderEvent.PAY"); + assertLinkedEvent( + linkEndpointToTransition(shipChain, MACHINE_CONFIG, + transition("NEW", "PAID", "OrderEvent.PAY"), + transition("PAID", "SHIPPED", "OrderEvent.SHIP")), + "OrderEvent.SHIP"); + } + + /** + * Tier 1 — DTO field set via constructor in controller, read via getter in gateway. + * + *
+     * submit() → gateway.submit(new OrderRequest(OrderEvent.SUBMIT)) → req.getType() → OrderEvent.SUBMIT
+     * 
+ */ + @Test + void dtoCrossHopFieldFlowShouldReachCentralDispatcher(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + CommandGateway gateway; + public void submit() { gateway.submit(new OrderRequest(OrderEvent.SUBMIT)); } + } + class CommandGateway { + void submit(OrderRequest req) { + StateMachine sm = new StateMachine(); + sm.sendEvent(req.getType()); + } + } + class OrderRequest { + private final OrderEvent type; + OrderRequest(OrderEvent type) { this.type = type; } + OrderEvent getType() { return type; } + } + enum OrderEvent { SUBMIT, PAY } + class StateMachine { public void sendEvent(OrderEvent e) {} } + class OrderStateMachineConfig {} + """; + CodebaseContext context = scanSource(source, tempDir); + + CallChain submitChain = resolveChain(context, CONTROLLER, "submit", STATE_MACHINE); + + assertPolyEvents(submitChain, "OrderEvent.SUBMIT"); + assertLinkedEvent( + linkEndpointToTransition(submitChain, MACHINE_CONFIG, + transition("DRAFT", "SUBMITTED", "OrderEvent.SUBMIT"), + transition("SUBMITTED", "PAID", "OrderEvent.PAY")), + "OrderEvent.SUBMIT"); + } + /** * Chained field-backed trivial getters ({@code return this.field}) through a central dispatcher. */ diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherTestSupport.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherTestSupport.java index 3162899..f3cad0d 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherTestSupport.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherTestSupport.java @@ -26,6 +26,11 @@ final class CentralDispatcherTestSupport { private CentralDispatcherTestSupport() { } + enum EngineKind { + HEURISTIC, + JDT + } + static CodebaseContext scanSource(String source, Path tempDir) throws Exception { Files.writeString(tempDir.resolve("App.java"), source); CodebaseContext context = new CodebaseContext(); @@ -38,7 +43,18 @@ final class CentralDispatcherTestSupport { String controllerClass, String controllerMethod, String stateMachineClass) { - HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, EngineKind.HEURISTIC); + } + + static CallChain resolveChain( + CodebaseContext context, + String controllerClass, + String controllerMethod, + String stateMachineClass, + EngineKind engineKind) { + AbstractCallGraphEngine engine = engineKind == EngineKind.JDT + ? new JdtCallGraphEngine(context, null) + : new HeuristicCallGraphEngine(context); EntryPoint entryPoint = EntryPoint.builder() .className(controllerClass) .methodName(controllerMethod) @@ -50,7 +66,8 @@ final class CentralDispatcherTestSupport { .build(); List chains = engine.findChains(List.of(entryPoint), List.of(trigger)); assertThat(chains) - .as("expected one call chain from %s.%s to %s.sendEvent", controllerClass, controllerMethod, stateMachineClass) + .as("expected one call chain from %s.%s to %s.sendEvent via %s", + controllerClass, controllerMethod, stateMachineClass, engineKind) .hasSize(1); return chains.get(0); } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CustomMessageTriggerResolutionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CustomMessageTriggerResolutionTest.java new file mode 100644 index 0000000..cf12050 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CustomMessageTriggerResolutionTest.java @@ -0,0 +1,63 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.nio.file.Path; +import java.util.List; + +import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.EngineKind; +import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.assertPolyEvents; +import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.scanSource; +import static org.assertj.core.api.Assertions.assertThat; + +class CustomMessageTriggerResolutionTest { + + private static final String SOURCE = """ + package com.example; + enum OrderEvent { PROCESS, COMPLETE } + class CustomStateMachineMessage { + CustomStateMachineMessage(T payload, P customPayload) {} + } + class StateMachineService { + void sendCustomMessage(CustomStateMachineMessage customMessage) {} + } + class RabbitOrderListener { + private StateMachineService stateMachineService; + void handleCustomTransition(String payload) { + CustomStateMachineMessage customMessage = + new CustomStateMachineMessage<>(OrderEvent.PROCESS, "payload: " + payload); + stateMachineService.sendCustomMessage(customMessage); + } + } + """; + + @ParameterizedTest + @EnumSource(EngineKind.class) + void customStateMachineMessageShouldExtractEventFromConstructor(EngineKind engineKind, @TempDir Path tempDir) + throws Exception { + var context = scanSource(SOURCE, tempDir); + AbstractCallGraphEngine engine = engineKind == EngineKind.JDT + ? new JdtCallGraphEngine(context, null) + : new HeuristicCallGraphEngine(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.RabbitOrderListener") + .methodName("handleCustomTransition") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachineService") + .methodName("sendCustomMessage") + .event("customMessage") + .build(); + + List chains = engine.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + assertPolyEvents(chains.get(0), "OrderEvent.PROCESS"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/InheritanceSampleChainTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/InheritanceSampleChainTest.java new file mode 100644 index 0000000..fb0b3ce --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/InheritanceSampleChainTest.java @@ -0,0 +1,60 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.exporter.JsonExporter; +import click.kamil.springstatemachineexporter.service.ExportService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification for {@code state_machines/inheritance_sample}: + * interface-mapped REST → injected service impl → {@code sendEvent}. + */ +class InheritanceSampleChainTest { + + private static Path findProjectRoot() { + Path current = Path.of(".").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("settings.gradle"))) { + current = current.getParent(); + } + return current; + } + + @Test + void exportShouldIncludeTriggerAndCallChain(@TempDir Path tempDir) throws Exception { + Path sampleRoot = findProjectRoot().resolve("state_machines/inheritance_sample"); + + ExportService exportService = new ExportService(List.of(new JsonExporter())); + exportService.runExporter(sampleRoot, tempDir, List.of("json"), true, List.of(), null, null, + click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, + click.kamil.springstatemachineexporter.exporter.EnumFormat.fn); + + ObjectMapper mapper = new ObjectMapper(); + Path jsonFile; + try (var stream = Files.walk(tempDir)) { + jsonFile = stream.filter(p -> p.getFileName().toString().contains("InheritanceStateMachineConfig") + && p.getFileName().toString().endsWith(".json")).findFirst().orElseThrow(); + } + JsonNode metadata = mapper.readTree(jsonFile.toFile()).path("metadata"); + + assertThat(metadata.path("triggers").size()).as("triggers").isGreaterThan(0); + assertThat(metadata.path("callChains").size()).as("callChains").isGreaterThan(0); + + JsonNode chain = metadata.path("callChains").get(0); + assertThat(chain.path("entryPoint").path("name").asText()) + .isEqualTo("POST /api/v2/orders/submit"); + assertThat(chain.path("methodChain").toString()) + .contains("OrderControllerImpl.submitOrder") + .contains("ProcessingServiceImpl.doProcess"); + assertThat(chain.path("triggerPoint").path("event").asText()) + .isIn("INHERITED_SUBMIT", "java.lang.String.INHERITED_SUBMIT", "String.INHERITED_SUBMIT"); + assertThat(chain.path("matchedTransitions").size()).isEqualTo(1); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtCentralDispatcherParityTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtCentralDispatcherParityTest.java new file mode 100644 index 0000000..24507e1 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtCentralDispatcherParityTest.java @@ -0,0 +1,148 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; + +import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.EngineKind; +import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Ensures the production {@link JdtCallGraphEngine} resolves single-dispatcher endpoints the same + * way as {@link HeuristicCallGraphEngine} for Tier-1 central-dispatcher patterns. + */ +class JdtCentralDispatcherParityTest { + + private static final String CONTROLLER = "com.example.ApiController"; + private static final String STATE_MACHINE = "com.example.StateMachine"; + private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig"; + + @Test + void jdtShouldMatchHeuristicForFieldWiredRoutingHelper(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + CommandGateway gateway; + public void pay() { gateway.routeAndPay(); } + public void ship() { gateway.routeAndShip(); } + } + class CommandGateway { + OrderRoutingHelper routingHelper; + CentralDispatcher dispatcher; + void routeAndPay() { dispatcher.dispatch("ORDER", routingHelper.payAction()); } + void routeAndShip() { dispatcher.dispatch("ORDER", routingHelper.shipAction()); } + } + class OrderRoutingHelper { + String payAction() { return "PAY"; } + String shipAction() { return "SHIP"; } + } + class CentralDispatcher { + void dispatch(String domain, String action) { + StateMachine sm = new StateMachine(); + sm.sendEvent(OrderEvent.valueOf(action)); + } + } + enum OrderEvent { PAY, SHIP } + class StateMachine { public void sendEvent(OrderEvent e) {} } + class OrderStateMachineConfig {} + """; + CodebaseContext context = scanSource(source, tempDir); + + CallChain heuristicPay = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE, EngineKind.HEURISTIC); + CallChain jdtPay = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE, EngineKind.JDT); + CallChain heuristicShip = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE, EngineKind.HEURISTIC); + CallChain jdtShip = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE, EngineKind.JDT); + + assertThat(jdtPay.getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrderElementsOf(heuristicPay.getTriggerPoint().getPolymorphicEvents()); + assertThat(jdtShip.getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrderElementsOf(heuristicShip.getTriggerPoint().getPolymorphicEvents()); + + MatchedTransition heuristicLink = linkEndpointToTransition(heuristicPay, MACHINE_CONFIG, + transition("NEW", "PAID", "OrderEvent.PAY"), + transition("PAID", "SHIPPED", "OrderEvent.SHIP")); + MatchedTransition jdtLink = linkEndpointToTransition(jdtPay, MACHINE_CONFIG, + transition("NEW", "PAID", "OrderEvent.PAY"), + transition("PAID", "SHIPPED", "OrderEvent.SHIP")); + assertThat(jdtLink.getEvent()).isEqualTo(heuristicLink.getEvent()); + } + + @Test + void jdtShouldMatchHeuristicForDtoCrossHop(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + CommandGateway gateway; + public void submit() { gateway.submit(new OrderRequest(OrderEvent.SUBMIT)); } + } + class CommandGateway { + void submit(OrderRequest req) { + StateMachine sm = new StateMachine(); + sm.sendEvent(req.getType()); + } + } + class OrderRequest { + private final OrderEvent type; + OrderRequest(OrderEvent type) { this.type = type; } + OrderEvent getType() { return type; } + } + enum OrderEvent { SUBMIT, PAY } + class StateMachine { public void sendEvent(OrderEvent e) {} } + class OrderStateMachineConfig {} + """; + CodebaseContext context = scanSource(source, tempDir); + + CallChain heuristic = resolveChain(context, CONTROLLER, "submit", STATE_MACHINE, EngineKind.HEURISTIC); + CallChain jdt = resolveChain(context, CONTROLLER, "submit", STATE_MACHINE, EngineKind.JDT); + + assertThat(jdt.getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrderElementsOf(heuristic.getTriggerPoint().getPolymorphicEvents()); + + MatchedTransition heuristicLink = linkEndpointToTransition(heuristic, MACHINE_CONFIG, + transition("DRAFT", "SUBMITTED", "OrderEvent.SUBMIT"), + transition("SUBMITTED", "PAID", "OrderEvent.PAY")); + MatchedTransition jdtLink = linkEndpointToTransition(jdt, MACHINE_CONFIG, + transition("DRAFT", "SUBMITTED", "OrderEvent.SUBMIT"), + transition("SUBMITTED", "PAID", "OrderEvent.PAY")); + assertThat(jdtLink.getEvent()).isEqualTo(heuristicLink.getEvent()); + } + + @Test + void jdtShouldMatchHeuristicForSingleDispatcherNamedMethods(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + CentralDispatcher dispatcher; + public void pay() { dispatcher.orderPay(); } + public void ship() { dispatcher.orderShip(); } + } + class CentralDispatcher { + public void orderPay() { + StateMachine sm = new StateMachine(); + sm.sendEvent(OrderEvent.PAY); + } + public void orderShip() { + StateMachine sm = new StateMachine(); + sm.sendEvent(OrderEvent.SHIP); + } + } + enum OrderEvent { PAY, SHIP } + class StateMachine { public void sendEvent(OrderEvent e) {} } + class OrderStateMachineConfig {} + """; + CodebaseContext context = scanSource(source, tempDir); + + for (String method : List.of("pay", "ship")) { + CallChain heuristic = resolveChain(context, CONTROLLER, method, STATE_MACHINE, EngineKind.HEURISTIC); + CallChain jdt = resolveChain(context, CONTROLLER, method, STATE_MACHINE, EngineKind.JDT); + assertThat(jdt.getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrderElementsOf(heuristic.getTriggerPoint().getPolymorphicEvents()); + } + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetectorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetectorTest.java index bd02e79..251c554 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetectorTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetectorTest.java @@ -71,4 +71,40 @@ class SpringMvcDetectorTest { assertThat(entryPoints).anyMatch(ep -> "POST /api/orders/pay".equals(ep.getName()) && "pay".equals(ep.getMethodName())); } + + @Test + void shouldDetectInterfaceMappedRestEndpointOnImplOnly(@TempDir Path tempDir) throws Exception { + String apiSource = """ + package com.example; + import org.springframework.web.bind.annotation.*; + @RequestMapping("/api/v2/orders") + public interface OrderApi { + @PostMapping("/submit") + void submitOrder(); + } + """; + String implSource = """ + package com.example; + import org.springframework.web.bind.annotation.RestController; + @RestController + public class OrderControllerImpl implements OrderApi { + @Override + public void submitOrder() {} + } + """; + Files.writeString(tempDir.resolve("OrderApi.java"), apiSource); + Files.writeString(tempDir.resolve("OrderControllerImpl.java"), implSource); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + SpringMvcDetector detector = new SpringMvcDetector(context, context.getConstantResolver()); + org.eclipse.jdt.core.dom.TypeDeclaration impl = context.getTypeDeclaration("com.example.OrderControllerImpl"); + List entryPoints = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) impl.getRoot()); + + assertThat(entryPoints).hasSize(1); + assertThat(entryPoints.get(0).getName()).isEqualTo("POST /api/v2/orders/submit"); + assertThat(entryPoints.get(0).getClassName()).isEqualTo("com.example.OrderControllerImpl"); + } } diff --git a/state_machine_exporter/src/test/resources/golden/DocumentStateMachineConfiguration/DocumentStateMachineConfiguration.json b/state_machine_exporter/src/test/resources/golden/DocumentStateMachineConfiguration/DocumentStateMachineConfiguration.json index ad84eca..2636286 100644 --- a/state_machine_exporter/src/test/resources/golden/DocumentStateMachineConfiguration/DocumentStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/DocumentStateMachineConfiguration/DocumentStateMachineConfiguration.json @@ -147,120 +147,6 @@ } ] } ], "callChains" : [ { - "entryPoint" : { - "type" : "REST", - "name" : "POST /api/machine/document/submit", - "className" : "click.kamil.enterprise.web.StateMachineController", - "methodName" : "submitDocument", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", - "metadata" : { - "path" : "/api/machine/document/submit", - "verb" : "POST" - }, - "parameters" : [ { - "name" : "userId", - "type" : "String", - "annotations" : [ "RequestParam" ] - } ] - }, - "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.submitDocument", "click.kamil.enterprise.web.StateMachineDispatcher.submitDocument" ], - "triggerPoint" : { - "event" : "click.kamil.enterprise.machines.document.DocumentEvent.SUBMIT", - "className" : "click.kamil.enterprise.web.StateMachineDispatcher", - "methodName" : "submitDocument", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", - "sourceModule" : null, - "stateMachineId" : null, - "sourceState" : null, - "lineNumber" : 44, - "polymorphicEvents" : [ "click.kamil.enterprise.machines.document.DocumentEvent.SUBMIT" ], - "external" : false, - "constraint" : null, - "ambiguous" : false - }, - "contextMachineId" : null, - "matchedTransitions" : [ { - "sourceState" : "click.kamil.enterprise.machines.document.DocumentState.DRAFT", - "targetState" : "click.kamil.enterprise.machines.document.DocumentState.IN_REVIEW", - "event" : "click.kamil.enterprise.machines.document.DocumentEvent.SUBMIT" - } ] - }, { - "entryPoint" : { - "type" : "REST", - "name" : "POST /api/machine/document/approve", - "className" : "click.kamil.enterprise.web.StateMachineController", - "methodName" : "approveDocument", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", - "metadata" : { - "path" : "/api/machine/document/approve", - "verb" : "POST" - }, - "parameters" : [ { - "name" : "userId", - "type" : "String", - "annotations" : [ "RequestParam" ] - } ] - }, - "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.approveDocument", "click.kamil.enterprise.web.StateMachineDispatcher.approveDocument" ], - "triggerPoint" : { - "event" : "click.kamil.enterprise.machines.document.DocumentEvent.APPROVE", - "className" : "click.kamil.enterprise.web.StateMachineDispatcher", - "methodName" : "approveDocument", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", - "sourceModule" : null, - "stateMachineId" : null, - "sourceState" : null, - "lineNumber" : 51, - "polymorphicEvents" : [ "click.kamil.enterprise.machines.document.DocumentEvent.APPROVE" ], - "external" : false, - "constraint" : null, - "ambiguous" : false - }, - "contextMachineId" : null, - "matchedTransitions" : [ { - "sourceState" : "click.kamil.enterprise.machines.document.DocumentState.IN_REVIEW", - "targetState" : "click.kamil.enterprise.machines.document.DocumentState.APPROVED", - "event" : "click.kamil.enterprise.machines.document.DocumentEvent.APPROVE" - } ] - }, { - "entryPoint" : { - "type" : "REST", - "name" : "POST /api/machine/document/reject", - "className" : "click.kamil.enterprise.web.StateMachineController", - "methodName" : "rejectDocument", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", - "metadata" : { - "path" : "/api/machine/document/reject", - "verb" : "POST" - }, - "parameters" : [ { - "name" : "userId", - "type" : "String", - "annotations" : [ "RequestParam" ] - } ] - }, - "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.rejectDocument", "click.kamil.enterprise.web.StateMachineDispatcher.rejectDocument" ], - "triggerPoint" : { - "event" : "click.kamil.enterprise.machines.document.DocumentEvent.REJECT", - "className" : "click.kamil.enterprise.web.StateMachineDispatcher", - "methodName" : "rejectDocument", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", - "sourceModule" : null, - "stateMachineId" : null, - "sourceState" : null, - "lineNumber" : 58, - "polymorphicEvents" : [ "click.kamil.enterprise.machines.document.DocumentEvent.REJECT" ], - "external" : false, - "constraint" : null, - "ambiguous" : false - }, - "contextMachineId" : null, - "matchedTransitions" : [ { - "sourceState" : "click.kamil.enterprise.machines.document.DocumentState.IN_REVIEW", - "targetState" : "click.kamil.enterprise.machines.document.DocumentState.DRAFT", - "event" : "click.kamil.enterprise.machines.document.DocumentEvent.REJECT" - } ] - }, { "entryPoint" : { "type" : "REST", "name" : "POST /api/machine/{machineType}/transition/{event}", diff --git a/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json index a7d8693..a79b895 100644 --- a/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json @@ -182,8 +182,112 @@ "stateTypeFqn" : "String", "eventTypeFqn" : "String", "metadata" : { - "triggers" : [ ], + "triggers" : [ { + "event" : "java.lang.String.AUDIT_EVENT", + "className" : "click.kamil.examples.enterprise.api.SecurityInterceptor", + "methodName" : "preHandle", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/api/SecurityInterceptor.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 17, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, { + "event" : "java.lang.String.PLACE_ORDER", + "className" : "click.kamil.examples.enterprise.service.OrderServiceImpl", + "methodName" : "place", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/service/OrderServiceImpl.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 16, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, { + "event" : "java.lang.String.CANCEL_ORDER", + "className" : "click.kamil.examples.enterprise.service.OrderServiceImpl", + "methodName" : "cancel", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/service/OrderServiceImpl.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 21, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, { + "event" : "java.lang.String.PAY_ORDER", + "className" : "click.kamil.examples.enterprise.service.ReactivePaymentService", + "methodName" : "processPayment", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/service/ReactivePaymentService.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 18, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, { + "event" : "java.lang.String.SHIP_ORDER", + "className" : "click.kamil.examples.enterprise.messaging.ShippingJmsListener", + "methodName" : "onShippingReady", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/messaging/ShippingJmsListener.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 17, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, { + "event" : "java.lang.String.RETURN_ORDER", + "className" : "click.kamil.examples.enterprise.messaging.ReturnsRabbitListener", + "methodName" : "onReturn", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/messaging/ReturnsRabbitListener.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 17, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + } ], "entryPoints" : [ { + "type" : "REST", + "name" : "POST /api/enterprise/orders/place", + "className" : "click.kamil.examples.enterprise.api.OrderController", + "methodName" : "placeOrder", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/api/OrderController.java", + "metadata" : { + "path" : "/api/enterprise/orders/place", + "verb" : "POST" + }, + "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/enterprise/orders/{id}/cancel", + "className" : "click.kamil.examples.enterprise.api.OrderController", + "methodName" : "cancelOrder", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/api/OrderController.java", + "metadata" : { + "path" : "/api/enterprise/orders/{id}/cancel", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "id", + "type" : "String", + "annotations" : [ "PathVariable" ] + } ] + }, { "type" : "CUSTOM", "name" : "INTERCEPTOR: SecurityInterceptor.preHandle", "className" : "click.kamil.examples.enterprise.api.SecurityInterceptor", @@ -239,7 +343,222 @@ "annotations" : [ ] } ] } ], - "callChains" : [ ], + "callChains" : [ { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/enterprise/orders/place", + "className" : "click.kamil.examples.enterprise.api.OrderController", + "methodName" : "placeOrder", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/api/OrderController.java", + "metadata" : { + "path" : "/api/enterprise/orders/place", + "verb" : "POST" + }, + "parameters" : [ ] + }, + "methodChain" : [ "click.kamil.examples.enterprise.api.OrderController.placeOrder", "click.kamil.examples.enterprise.service.OrderServiceImpl.place" ], + "triggerPoint" : { + "event" : "java.lang.String.PLACE_ORDER", + "className" : "click.kamil.examples.enterprise.service.OrderServiceImpl", + "methodName" : "place", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/service/OrderServiceImpl.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 16, + "polymorphicEvents" : [ "java.lang.String.PLACE_ORDER" ], + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "String.NEW", + "targetState" : "String.CHECK_AVAILABILITY", + "event" : "String.PLACE_ORDER" + } ] + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/enterprise/orders/{id}/cancel", + "className" : "click.kamil.examples.enterprise.api.OrderController", + "methodName" : "cancelOrder", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/api/OrderController.java", + "metadata" : { + "path" : "/api/enterprise/orders/{id}/cancel", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "id", + "type" : "String", + "annotations" : [ "PathVariable" ] + } ] + }, + "methodChain" : [ "click.kamil.examples.enterprise.api.OrderController.cancelOrder", "click.kamil.examples.enterprise.service.OrderServiceImpl.cancel" ], + "triggerPoint" : { + "event" : "java.lang.String.CANCEL_ORDER", + "className" : "click.kamil.examples.enterprise.service.OrderServiceImpl", + "methodName" : "cancel", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/service/OrderServiceImpl.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 21, + "polymorphicEvents" : [ "java.lang.String.CANCEL_ORDER" ], + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "String.PAID", + "targetState" : "String.CANCELLED", + "event" : "String.CANCEL_ORDER" + } ] + }, { + "entryPoint" : { + "type" : "CUSTOM", + "name" : "INTERCEPTOR: SecurityInterceptor.preHandle", + "className" : "click.kamil.examples.enterprise.api.SecurityInterceptor", + "methodName" : "preHandle", + "sourceFile" : null, + "metadata" : { + "interceptorType" : "Spring MVC Interceptor" + }, + "parameters" : [ ] + }, + "methodChain" : [ "click.kamil.examples.enterprise.api.SecurityInterceptor.preHandle" ], + "triggerPoint" : { + "event" : "java.lang.String.AUDIT_EVENT", + "className" : "click.kamil.examples.enterprise.api.SecurityInterceptor", + "methodName" : "preHandle", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/api/SecurityInterceptor.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 17, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : null + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/enterprise/payments", + "className" : "click.kamil.examples.enterprise.api.ReactivePaymentController", + "methodName" : "pay", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/api/ReactivePaymentController.java", + "metadata" : { + "path" : "/api/enterprise/payments", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "orderId", + "type" : "String", + "annotations" : [ "RequestBody" ] + } ] + }, + "methodChain" : [ "click.kamil.examples.enterprise.api.ReactivePaymentController.pay", "click.kamil.examples.enterprise.service.ReactivePaymentService.processPayment" ], + "triggerPoint" : { + "event" : "java.lang.String.PAY_ORDER", + "className" : "click.kamil.examples.enterprise.service.ReactivePaymentService", + "methodName" : "processPayment", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/service/ReactivePaymentService.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 18, + "polymorphicEvents" : [ "java.lang.String.PAY_ORDER" ], + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "String.PENDING_PAYMENT", + "targetState" : "String.PAID", + "event" : "String.PAY_ORDER" + } ] + }, { + "entryPoint" : { + "type" : "JMS", + "name" : "JMS: shipping.queue", + "className" : "click.kamil.examples.enterprise.messaging.ShippingJmsListener", + "methodName" : "onShippingReady", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/messaging/ShippingJmsListener.java", + "metadata" : { + "protocol" : "JMS", + "destination" : "shipping.queue" + }, + "parameters" : [ { + "name" : "orderId", + "type" : "String", + "annotations" : [ ] + } ] + }, + "methodChain" : [ "click.kamil.examples.enterprise.messaging.ShippingJmsListener.onShippingReady" ], + "triggerPoint" : { + "event" : "java.lang.String.SHIP_ORDER", + "className" : "click.kamil.examples.enterprise.messaging.ShippingJmsListener", + "methodName" : "onShippingReady", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/messaging/ShippingJmsListener.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 17, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "String.PAID", + "targetState" : "String.SHIPPED", + "event" : "String.SHIP_ORDER" + } ] + }, { + "entryPoint" : { + "type" : "RABBIT", + "name" : "RABBIT: returns.queue", + "className" : "click.kamil.examples.enterprise.messaging.ReturnsRabbitListener", + "methodName" : "onReturn", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/messaging/ReturnsRabbitListener.java", + "metadata" : { + "protocol" : "RABBIT", + "destination" : "returns.queue" + }, + "parameters" : [ { + "name" : "orderId", + "type" : "String", + "annotations" : [ ] + } ] + }, + "methodChain" : [ "click.kamil.examples.enterprise.messaging.ReturnsRabbitListener.onReturn" ], + "triggerPoint" : { + "event" : "java.lang.String.RETURN_ORDER", + "className" : "click.kamil.examples.enterprise.messaging.ReturnsRabbitListener", + "methodName" : "onReturn", + "sourceFile" : "src/main/java/click/kamil/examples/enterprise/messaging/ReturnsRabbitListener.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 17, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "String.DELIVERED", + "targetState" : "String.RETURNED", + "event" : "String.RETURN_ORDER" + } ] + } ], "properties" : { "default" : { } } diff --git a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json index 49f351a..f29cdfd 100644 --- a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json @@ -182,40 +182,6 @@ } ] } ], "callChains" : [ { - "entryPoint" : { - "type" : "REST", - "name" : "POST /api/orders/resume", - "className" : "click.kamil.examples.statemachine.extended.web.OrderController", - "methodName" : "resumeOrder", - "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java", - "metadata" : { - "path" : "/api/orders/resume", - "verb" : "POST" - }, - "parameters" : [ { - "name" : "orderId", - "type" : "String", - "annotations" : [ ] - } ] - }, - "methodChain" : [ "click.kamil.examples.statemachine.extended.web.OrderController.resumeOrder", "click.kamil.examples.statemachine.extended.service.OrderService.resumeOrder" ], - "triggerPoint" : { - "event" : "[LIFECYCLE:RESTORE]", - "className" : "click.kamil.examples.statemachine.extended.service.OrderService", - "methodName" : "resumeOrder", - "sourceFile" : null, - "sourceModule" : null, - "stateMachineId" : null, - "sourceState" : null, - "lineNumber" : 29, - "polymorphicEvents" : null, - "external" : false, - "constraint" : null, - "ambiguous" : false - }, - "contextMachineId" : "orderId", - "matchedTransitions" : null - }, { "entryPoint" : { "type" : "REST", "name" : "POST /api/payment/{id}/capture", diff --git a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.json index 49f351a..f29cdfd 100644 --- a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.json @@ -182,40 +182,6 @@ } ] } ], "callChains" : [ { - "entryPoint" : { - "type" : "REST", - "name" : "POST /api/orders/resume", - "className" : "click.kamil.examples.statemachine.extended.web.OrderController", - "methodName" : "resumeOrder", - "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java", - "metadata" : { - "path" : "/api/orders/resume", - "verb" : "POST" - }, - "parameters" : [ { - "name" : "orderId", - "type" : "String", - "annotations" : [ ] - } ] - }, - "methodChain" : [ "click.kamil.examples.statemachine.extended.web.OrderController.resumeOrder", "click.kamil.examples.statemachine.extended.service.OrderService.resumeOrder" ], - "triggerPoint" : { - "event" : "[LIFECYCLE:RESTORE]", - "className" : "click.kamil.examples.statemachine.extended.service.OrderService", - "methodName" : "resumeOrder", - "sourceFile" : null, - "sourceModule" : null, - "stateMachineId" : null, - "sourceState" : null, - "lineNumber" : 29, - "polymorphicEvents" : null, - "external" : false, - "constraint" : null, - "ambiguous" : false - }, - "contextMachineId" : "orderId", - "matchedTransitions" : null - }, { "entryPoint" : { "type" : "REST", "name" : "POST /api/payment/{id}/capture", diff --git a/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json index 07a2349..2af43e6 100644 --- a/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json @@ -32,9 +32,67 @@ "stateTypeFqn" : "String", "eventTypeFqn" : "String", "metadata" : { - "triggers" : [ ], - "entryPoints" : [ ], - "callChains" : [ ], + "triggers" : [ { + "event" : "java.lang.String.INHERITED_SUBMIT", + "className" : "click.kamil.examples.statemachine.inheritance.service.ProcessingServiceImpl", + "methodName" : "doProcess", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/inheritance/service/ProcessingServiceImpl.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 15, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + } ], + "entryPoints" : [ { + "type" : "REST", + "name" : "POST /api/v2/orders/submit", + "className" : "click.kamil.examples.statemachine.inheritance.api.OrderControllerImpl", + "methodName" : "submitOrder", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/inheritance/api/OrderControllerImpl.java", + "metadata" : { + "path" : "/api/v2/orders/submit", + "verb" : "POST" + }, + "parameters" : [ ] + } ], + "callChains" : [ { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/v2/orders/submit", + "className" : "click.kamil.examples.statemachine.inheritance.api.OrderControllerImpl", + "methodName" : "submitOrder", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/inheritance/api/OrderControllerImpl.java", + "metadata" : { + "path" : "/api/v2/orders/submit", + "verb" : "POST" + }, + "parameters" : [ ] + }, + "methodChain" : [ "click.kamil.examples.statemachine.inheritance.api.OrderControllerImpl.submitOrder", "click.kamil.examples.statemachine.inheritance.service.ProcessingServiceImpl.doProcess" ], + "triggerPoint" : { + "event" : "java.lang.String.INHERITED_SUBMIT", + "className" : "click.kamil.examples.statemachine.inheritance.service.ProcessingServiceImpl", + "methodName" : "doProcess", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/inheritance/service/ProcessingServiceImpl.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 15, + "polymorphicEvents" : [ "java.lang.String.INHERITED_SUBMIT" ], + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "String.START", + "targetState" : "String.WORKING", + "event" : "String.INHERITED_SUBMIT" + } ] + } ], "properties" : { "default" : { } } diff --git a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json index 315c8ae..af67ae5 100644 --- a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json +++ b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json @@ -60,8 +60,61 @@ "eventTypeFqn" : "String", "metadata" : { "triggers" : [ ], - "entryPoints" : [ ], - "callChains" : [ ], + "entryPoints" : [ { + "type" : "REST", + "name" : "POST /maven/orders/submit", + "className" : "click.kamil.maven.core.MavenOrderStateMachine.OrderController", + "methodName" : "submitOrder", + "sourceFile" : "core-module/src/main/java/click/kamil/maven/core/MavenOrderStateMachine.java", + "metadata" : { + "path" : "/maven/orders/submit", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "orderId", + "type" : "String", + "annotations" : [ "RequestBody" ] + } ] + } ], + "callChains" : [ { + "entryPoint" : { + "type" : "REST", + "name" : "POST /maven/orders/submit", + "className" : "click.kamil.maven.core.MavenOrderStateMachine.OrderController", + "methodName" : "submitOrder", + "sourceFile" : "core-module/src/main/java/click/kamil/maven/core/MavenOrderStateMachine.java", + "metadata" : { + "path" : "/maven/orders/submit", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "orderId", + "type" : "String", + "annotations" : [ "RequestBody" ] + } ] + }, + "methodChain" : [ "click.kamil.maven.core.MavenOrderStateMachine.OrderController.submitOrder" ], + "triggerPoint" : { + "event" : "java.lang.String.SUBMIT", + "className" : "click.kamil.maven.core.MavenOrderStateMachine.OrderController", + "methodName" : "submitOrder", + "sourceFile" : "core-module/src/main/java/click/kamil/maven/core/MavenOrderStateMachine.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 40, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "String.INIT", + "targetState" : "String.BUSY", + "event" : "String.SUBMIT" + } ] + } ], "properties" : { "default" : { } } diff --git a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.json b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.json index 9556b89..d821812 100644 --- a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.json +++ b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.json @@ -52,7 +52,7 @@ "rawName" : "\"BUSY\"", "fullIdentifier" : "BUSY" } ], - "event" : "ORDER_EVENT", + "event" : "SUBMIT", "guard" : null, "actions" : [ { "expression" : "loggingAction()", @@ -61,6 +61,20 @@ "lineNumber" : 23 } ], "order" : null + }, { + "type" : "EXTERNAL", + "sourceStates" : [ { + "rawName" : "\"BUSY\"", + "fullIdentifier" : "BUSY" + } ], + "targetStates" : [ { + "rawName" : "\"DONE\"", + "fullIdentifier" : "DONE" + } ], + "event" : "ORDER_EVENT", + "guard" : null, + "actions" : [ ], + "order" : null } ], "endStates" : [ "DONE" ] } diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json index 5769f3a..57bb331 100644 --- a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json @@ -59,7 +59,20 @@ "stateTypeFqn" : "String", "eventTypeFqn" : "String", "metadata" : { - "triggers" : [ ], + "triggers" : [ { + "event" : "java.lang.String.SUBMIT", + "className" : "click.kamil.multi.core.OrderController", + "methodName" : "submitOrder", + "sourceFile" : "core-module/src/main/java/click/kamil/multi/core/OrderController.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 18, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + } ], "entryPoints" : [ { "type" : "REST", "name" : "POST /orders/submit", @@ -75,23 +88,46 @@ "type" : "String", "annotations" : [ "RequestBody" ] } ] - }, { - "type" : "REST", - "name" : "POST /orders/submit", - "className" : "click.kamil.multi.api.OrderApi", - "methodName" : "submitOrder", - "sourceFile" : "api-module/src/main/java/click/kamil/multi/api/OrderApi.java", - "metadata" : { - "path" : "/orders/submit", - "verb" : "POST" + } ], + "callChains" : [ { + "entryPoint" : { + "type" : "REST", + "name" : "POST /orders/submit", + "className" : "click.kamil.multi.core.OrderController", + "methodName" : "submitOrder", + "sourceFile" : "core-module/src/main/java/click/kamil/multi/core/OrderController.java", + "metadata" : { + "path" : "/orders/submit", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "orderId", + "type" : "String", + "annotations" : [ "RequestBody" ] + } ] }, - "parameters" : [ { - "name" : "orderId", - "type" : "String", - "annotations" : [ "RequestBody" ] + "methodChain" : [ "click.kamil.multi.core.OrderController.submitOrder" ], + "triggerPoint" : { + "event" : "java.lang.String.SUBMIT", + "className" : "click.kamil.multi.core.OrderController", + "methodName" : "submitOrder", + "sourceFile" : "core-module/src/main/java/click/kamil/multi/core/OrderController.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 18, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "String.NEW", + "targetState" : "String.PROCESSING", + "event" : "String.SUBMIT" } ] } ], - "callChains" : [ ], "properties" : { "default" : { } } diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfiguration/OrderStateMachineConfiguration.json b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfiguration/OrderStateMachineConfiguration.json index dded150..86aa4b5 100644 --- a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfiguration/OrderStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfiguration/OrderStateMachineConfiguration.json @@ -124,82 +124,6 @@ } ] } ], "callChains" : [ { - "entryPoint" : { - "type" : "REST", - "name" : "POST /api/machine/order/pay", - "className" : "click.kamil.enterprise.web.StateMachineController", - "methodName" : "payOrder", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", - "metadata" : { - "path" : "/api/machine/order/pay", - "verb" : "POST" - }, - "parameters" : [ { - "name" : "userId", - "type" : "String", - "annotations" : [ "RequestParam" ] - } ] - }, - "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.payOrder", "click.kamil.enterprise.web.StateMachineDispatcher.payOrder" ], - "triggerPoint" : { - "event" : "click.kamil.enterprise.machines.order.OrderEvent.PAY", - "className" : "click.kamil.enterprise.web.StateMachineDispatcher", - "methodName" : "payOrder", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", - "sourceModule" : null, - "stateMachineId" : null, - "sourceState" : null, - "lineNumber" : 30, - "polymorphicEvents" : [ "click.kamil.enterprise.machines.order.OrderEvent.PAY" ], - "external" : false, - "constraint" : null, - "ambiguous" : false - }, - "contextMachineId" : null, - "matchedTransitions" : [ { - "sourceState" : "click.kamil.enterprise.machines.order.OrderState.NEW", - "targetState" : "click.kamil.enterprise.machines.order.OrderState.PENDING", - "event" : "click.kamil.enterprise.machines.order.OrderEvent.PAY" - } ] - }, { - "entryPoint" : { - "type" : "REST", - "name" : "POST /api/machine/order/ship", - "className" : "click.kamil.enterprise.web.StateMachineController", - "methodName" : "shipOrder", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", - "metadata" : { - "path" : "/api/machine/order/ship", - "verb" : "POST" - }, - "parameters" : [ { - "name" : "userId", - "type" : "String", - "annotations" : [ "RequestParam" ] - } ] - }, - "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.shipOrder", "click.kamil.enterprise.web.StateMachineDispatcher.shipOrder" ], - "triggerPoint" : { - "event" : "click.kamil.enterprise.machines.order.OrderEvent.SHIP", - "className" : "click.kamil.enterprise.web.StateMachineDispatcher", - "methodName" : "shipOrder", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", - "sourceModule" : null, - "stateMachineId" : null, - "sourceState" : null, - "lineNumber" : 37, - "polymorphicEvents" : [ "click.kamil.enterprise.machines.order.OrderEvent.SHIP" ], - "external" : false, - "constraint" : null, - "ambiguous" : false - }, - "contextMachineId" : null, - "matchedTransitions" : [ { - "sourceState" : "click.kamil.enterprise.machines.order.OrderState.PENDING", - "targetState" : "click.kamil.enterprise.machines.order.OrderState.SHIPPED", - "event" : "click.kamil.enterprise.machines.order.OrderEvent.SHIP" - } ] - }, { "entryPoint" : { "type" : "REST", "name" : "POST /api/machine/{machineType}/transition/{event}", diff --git a/state_machine_exporter/src/test/resources/golden/StandardDocumentStateMachineConfiguration/StandardDocumentStateMachineConfiguration.json b/state_machine_exporter/src/test/resources/golden/StandardDocumentStateMachineConfiguration/StandardDocumentStateMachineConfiguration.json index 8dea469..f6a6257 100644 --- a/state_machine_exporter/src/test/resources/golden/StandardDocumentStateMachineConfiguration/StandardDocumentStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/StandardDocumentStateMachineConfiguration/StandardDocumentStateMachineConfiguration.json @@ -87,6 +87,39 @@ "ambiguous" : false } ], "entryPoints" : [ { + "type" : "REST", + "name" : "POST /api/documents/submit", + "className" : "click.kamil.examples.statemachine.layered.web.DocumentController", + "methodName" : "submit", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/DocumentController.java", + "metadata" : { + "path" : "/api/documents/submit", + "verb" : "POST" + }, + "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/documents/approve", + "className" : "click.kamil.examples.statemachine.layered.web.DocumentController", + "methodName" : "approve", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/DocumentController.java", + "metadata" : { + "path" : "/api/documents/approve", + "verb" : "POST" + }, + "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/documents/reject", + "className" : "click.kamil.examples.statemachine.layered.web.DocumentController", + "methodName" : "reject", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/DocumentController.java", + "metadata" : { + "path" : "/api/documents/reject", + "verb" : "POST" + }, + "parameters" : [ ] + }, { "type" : "REST", "name" : "POST /api/commands/{commandKey}", "className" : "click.kamil.examples.statemachine.layered.web.GenericCommandController", diff --git a/state_machine_exporter/src/test/resources/golden/StandardOrderStateMachineConfiguration/StandardOrderStateMachineConfiguration.json b/state_machine_exporter/src/test/resources/golden/StandardOrderStateMachineConfiguration/StandardOrderStateMachineConfiguration.json index c03ee85..88802c3 100644 --- a/state_machine_exporter/src/test/resources/golden/StandardOrderStateMachineConfiguration/StandardOrderStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/StandardOrderStateMachineConfiguration/StandardOrderStateMachineConfiguration.json @@ -87,6 +87,83 @@ "ambiguous" : false } ], "entryPoints" : [ { + "type" : "REST", + "name" : "POST /api/orders/pay", + "className" : "click.kamil.examples.statemachine.layered.web.OrderController", + "methodName" : "pay", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/OrderController.java", + "metadata" : { + "path" : "/api/orders/pay", + "verb" : "POST" + }, + "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/orders/ship", + "className" : "click.kamil.examples.statemachine.layered.web.OrderController", + "methodName" : "ship", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/OrderController.java", + "metadata" : { + "path" : "/api/orders/ship", + "verb" : "POST" + }, + "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/orders/cancel", + "className" : "click.kamil.examples.statemachine.layered.web.OrderController", + "methodName" : "cancel", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/OrderController.java", + "metadata" : { + "path" : "/api/orders/cancel", + "verb" : "POST" + }, + "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/string-dispatch/orders/pay", + "className" : "click.kamil.examples.statemachine.layered.web.StringDispatchController", + "methodName" : "payWithStringKey", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/StringDispatchController.java", + "metadata" : { + "path" : "/api/string-dispatch/orders/pay", + "verb" : "POST" + }, + "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/string-dispatch/orders/ship", + "className" : "click.kamil.examples.statemachine.layered.web.StringDispatchController", + "methodName" : "shipWithStringKey", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/StringDispatchController.java", + "metadata" : { + "path" : "/api/string-dispatch/orders/ship", + "verb" : "POST" + }, + "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/orders/rich/pay", + "className" : "click.kamil.examples.statemachine.layered.web.RichOrderController", + "methodName" : "payViaRichEvent", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/RichOrderController.java", + "metadata" : { + "path" : "/api/orders/rich/pay", + "verb" : "POST" + }, + "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/orders/rich/ship", + "className" : "click.kamil.examples.statemachine.layered.web.RichOrderController", + "methodName" : "shipViaRichEvent", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/RichOrderController.java", + "metadata" : { + "path" : "/api/orders/rich/ship", + "verb" : "POST" + }, + "parameters" : [ ] + }, { "type" : "REST", "name" : "POST /api/commands/{commandKey}", "className" : "click.kamil.examples.statemachine.layered.web.GenericCommandController", diff --git a/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.json index 02a6065..6b5f1aa 100644 --- a/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.json @@ -130,6 +130,50 @@ "ambiguous" : false } ], "entryPoints" : [ { + "type" : "REST", + "name" : "POST /api/orders/process", + "className" : "click.kamil.web.OrderController", + "methodName" : "processOrder", + "sourceFile" : "web/src/main/java/click/kamil/web/OrderController.java", + "metadata" : { + "path" : "/api/orders/process", + "verb" : "POST" + }, + "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/orders/complete", + "className" : "click.kamil.web.OrderController", + "methodName" : "completeOrder", + "sourceFile" : "web/src/main/java/click/kamil/web/OrderController.java", + "metadata" : { + "path" : "/api/orders/complete", + "verb" : "POST" + }, + "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/orders/cancel", + "className" : "click.kamil.web.OrderController", + "methodName" : "cancelOrder", + "sourceFile" : "web/src/main/java/click/kamil/web/OrderController.java", + "metadata" : { + "path" : "/api/orders/cancel", + "verb" : "POST" + }, + "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/orders/functional-complete", + "className" : "click.kamil.web.OrderController", + "methodName" : "functionalCompleteOrder", + "sourceFile" : "web/src/main/java/click/kamil/web/OrderController.java", + "metadata" : { + "path" : "/api/orders/functional-complete", + "verb" : "POST" + }, + "parameters" : [ ] + }, { "type" : "RABBIT", "name" : "RABBIT: order.custom.transition.queue", "className" : "click.kamil.web.RabbitOrderListener", @@ -342,13 +386,17 @@ "stateMachineId" : null, "sourceState" : null, "lineNumber" : 78, - "polymorphicEvents" : [ ], + "polymorphicEvents" : [ "click.kamil.domain.OrderEvent.PROCESS" ], "external" : false, "constraint" : null, "ambiguous" : false }, "contextMachineId" : null, - "matchedTransitions" : null + "matchedTransitions" : [ { + "sourceState" : "click.kamil.domain.OrderState.NEW", + "targetState" : "click.kamil.domain.OrderState.PROCESSING", + "event" : "click.kamil.domain.OrderEvent.PROCESS" + } ] }, { "entryPoint" : { "type" : "JMS", diff --git a/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json b/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json index 2853656..24e9401 100644 --- a/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json @@ -117,78 +117,6 @@ } ] } ], "callChains" : [ { - "entryPoint" : { - "type" : "REST", - "name" : "POST /api/machine/user/verify", - "className" : "click.kamil.enterprise.web.StateMachineController", - "methodName" : "verifyUser", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", - "metadata" : { - "path" : "/api/machine/user/verify", - "verb" : "POST" - }, - "parameters" : [ { - "name" : "userId", - "type" : "String", - "annotations" : [ "RequestParam" ] - } ] - }, - "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.verifyUser", "click.kamil.enterprise.web.StateMachineDispatcher.verifyUser" ], - "triggerPoint" : { - "event" : "click.kamil.enterprise.machines.user.UserEvent.VERIFY", - "className" : "click.kamil.enterprise.web.StateMachineDispatcher", - "methodName" : "verifyUser", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", - "sourceModule" : null, - "stateMachineId" : null, - "sourceState" : null, - "lineNumber" : 65, - "polymorphicEvents" : [ "click.kamil.enterprise.machines.user.UserEvent.VERIFY" ], - "external" : false, - "constraint" : null, - "ambiguous" : false - }, - "contextMachineId" : null, - "matchedTransitions" : [ { - "sourceState" : "click.kamil.enterprise.machines.user.UserState.REGISTERED", - "targetState" : "click.kamil.enterprise.machines.user.UserState.VERIFIED", - "event" : "click.kamil.enterprise.machines.user.UserEvent.VERIFY" - } ] - }, { - "entryPoint" : { - "type" : "REST", - "name" : "POST /api/machine/user/suspend", - "className" : "click.kamil.enterprise.web.StateMachineController", - "methodName" : "suspendUser", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", - "metadata" : { - "path" : "/api/machine/user/suspend", - "verb" : "POST" - }, - "parameters" : [ { - "name" : "userId", - "type" : "String", - "annotations" : [ "RequestParam" ] - } ] - }, - "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.suspendUser", "click.kamil.enterprise.web.StateMachineDispatcher.suspendUser" ], - "triggerPoint" : { - "event" : "click.kamil.enterprise.machines.user.UserEvent.SUSPEND", - "className" : "click.kamil.enterprise.web.StateMachineDispatcher", - "methodName" : "suspendUser", - "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", - "sourceModule" : null, - "stateMachineId" : null, - "sourceState" : null, - "lineNumber" : 72, - "polymorphicEvents" : [ "click.kamil.enterprise.machines.user.UserEvent.SUSPEND" ], - "external" : false, - "constraint" : null, - "ambiguous" : false - }, - "contextMachineId" : null, - "matchedTransitions" : null - }, { "entryPoint" : { "type" : "REST", "name" : "POST /api/machine/{machineType}/transition/{event}",