diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainEnricher.java index c7fa1cf..0e2dc39 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainEnricher.java @@ -69,6 +69,7 @@ public class CallChainEnricher implements AnalysisEnricher { } List chains = intelligence.findCallChains(entryPoints, scopedTriggers); + // Semantic deduplication runs in TransitionLinkerEnricher after transitions are linked. return MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context); } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/SemanticCallChainDedup.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/SemanticCallChainDedup.java new file mode 100644 index 0000000..5b4ac88 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/SemanticCallChainDedup.java @@ -0,0 +1,271 @@ +package click.kamil.springstatemachineexporter.analysis.enricher; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; +import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Collapses call chains that represent the same logical entrypoint-to-transition link + * but were discovered via different call-graph paths. + */ +public final class SemanticCallChainDedup { + + public enum KeepStrategy { + SHORTEST, + FIRST + } + + private SemanticCallChainDedup() { + } + + public static List deduplicate(List chains) { + return deduplicate(chains, KeepStrategy.SHORTEST); + } + + public static List deduplicate(List chains, KeepStrategy strategy) { + if (chains == null || chains.isEmpty()) { + return chains == null ? List.of() : chains; + } + Map byKey = new LinkedHashMap<>(); + for (CallChain chain : chains) { + if (chain == null) { + continue; + } + String key = semanticKey(chain); + CallChain existing = byKey.get(key); + if (existing == null) { + byKey.put(key, chain); + } else { + byKey.put(key, pickPreferred(existing, chain, strategy)); + } + } + return new ArrayList<>(byKey.values()); + } + + public static List merge( + List existing, + List refreshed, + KeepStrategy strategy) { + List combined = new ArrayList<>(); + if (existing != null) { + combined.addAll(existing); + } + if (refreshed != null) { + combined.addAll(refreshed); + } + return deduplicate(combined, strategy); + } + + /** + * JSON refresh path: when refresh produces any chains for an entry point, those replace all + * existing chains for that entry (so unresolved stubs like {@code event} do not linger). + * Multiple refreshed chains per entry (multi-event fan-out) are preserved, then semantic + * dedup collapses path variants. + */ + public static List mergeRefresh( + List existing, + List refreshed, + KeepStrategy strategy) { + Map> existingByEntry = groupByEntryPoint(existing); + Map> refreshedByEntry = groupByEntryPoint(refreshed); + LinkedHashSet entries = new LinkedHashSet<>(); + entries.addAll(existingByEntry.keySet()); + entries.addAll(refreshedByEntry.keySet()); + + List combined = new ArrayList<>(); + for (String entryKey : entries) { + if (refreshedByEntry.containsKey(entryKey)) { + combined.addAll(refreshedByEntry.get(entryKey)); + } else { + combined.addAll(existingByEntry.getOrDefault(entryKey, List.of())); + } + } + return deduplicate(combined, strategy); + } + + private static Map> groupByEntryPoint(List chains) { + Map> byEntry = new LinkedHashMap<>(); + if (chains == null) { + return byEntry; + } + for (CallChain chain : chains) { + if (chain == null) { + continue; + } + byEntry.computeIfAbsent(entryPointKey(chain), key -> new ArrayList<>()).add(chain); + } + return byEntry; + } + + static String semanticKey(CallChain chain) { + StringBuilder key = new StringBuilder(); + key.append(entryPointKey(chain)).append('|'); + key.append(triggerKey(chain.getTriggerPoint())).append('|'); + key.append(linkResolutionKey(chain.getLinkResolution())).append('|'); + key.append(matchedTransitionsKey(chain.getMatchedTransitions())).append('|'); + key.append(nullToEmpty(chain.getContextMachineId())); + return key.toString(); + } + + private static CallChain pickPreferred(CallChain existing, CallChain candidate, KeepStrategy strategy) { + if (strategy == KeepStrategy.FIRST) { + return existing; + } + boolean existingAnchored = startsAtEntryPoint(existing); + boolean candidateAnchored = startsAtEntryPoint(candidate); + if (candidateAnchored != existingAnchored) { + return candidateAnchored ? candidate : existing; + } + int existingLen = chainLength(existing); + int candidateLen = chainLength(candidate); + if (candidateLen < existingLen) { + return candidate; + } + if (candidateLen > existingLen) { + return existing; + } + int existingRichness = metadataRichness(existing); + int candidateRichness = metadataRichness(candidate); + if (candidateRichness != existingRichness) { + return candidateRichness > existingRichness ? candidate : existing; + } + List existingChain = existing.getMethodChain(); + List candidateChain = candidate.getMethodChain(); + if (existingChain == null) { + return candidate; + } + if (candidateChain == null) { + return existing; + } + return candidateChain.toString().compareTo(existingChain.toString()) < 0 ? candidate : existing; + } + + /** + * True when the first hop equals the entry point method identity + * ({@code className.methodName} or {@code className#methodName} key form). + */ + static boolean startsAtEntryPoint(CallChain chain) { + if (chain == null) { + return false; + } + EntryPoint entryPoint = chain.getEntryPoint(); + List methodChain = chain.getMethodChain(); + if (entryPoint == null + || entryPoint.getClassName() == null + || entryPoint.getMethodName() == null + || methodChain == null + || methodChain.isEmpty()) { + return false; + } + String firstHop = methodChain.get(0); + if (firstHop == null || firstHop.isBlank()) { + return false; + } + String entryHop = entryPoint.getClassName() + "." + entryPoint.getMethodName(); + if (entryHop.equals(firstHop)) { + return true; + } + return normalizeMethodHopToEntryKey(firstHop) + .equals(entryPoint.getClassName() + "#" + entryPoint.getMethodName()); + } + + private static int metadataRichness(CallChain chain) { + int score = 0; + if (chain.getEntryPoint() != null) { + score += 2; + } + if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty()) { + score += 1; + } + if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) { + score += 4; + } + return score; + } + + private static int chainLength(CallChain chain) { + return chain.getMethodChain() == null ? Integer.MAX_VALUE : chain.getMethodChain().size(); + } + + private static String entryPointKey(CallChain chain) { + EntryPoint entryPoint = chain.getEntryPoint(); + if (entryPoint != null) { + if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) { + return entryPoint.getName(); + } + if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) { + return entryPoint.getClassName() + "#" + entryPoint.getMethodName(); + } + } + List methodChain = chain.getMethodChain(); + if (methodChain != null && !methodChain.isEmpty()) { + return normalizeMethodHopToEntryKey(methodChain.get(0)); + } + TriggerPoint trigger = chain.getTriggerPoint(); + if (trigger != null && trigger.getClassName() != null && trigger.getMethodName() != null) { + return trigger.getClassName() + "#" + trigger.getMethodName(); + } + return ""; + } + + private static String normalizeMethodHopToEntryKey(String hop) { + if (hop == null || hop.isBlank()) { + return ""; + } + int lastDot = hop.lastIndexOf('.'); + if (lastDot <= 0 || lastDot >= hop.length() - 1) { + return hop; + } + return hop.substring(0, lastDot) + "#" + hop.substring(lastDot + 1); + } + + private static String triggerKey(TriggerPoint trigger) { + if (trigger == null) { + return ""; + } + StringBuilder key = new StringBuilder(); + key.append(nullToEmpty(trigger.getClassName())).append('#'); + key.append(nullToEmpty(trigger.getMethodName())).append('|'); + key.append(nullToEmpty(trigger.getEvent())).append('|'); + key.append(nullToEmpty(trigger.getConstraint())).append('|'); + key.append(trigger.isExternal()).append('|'); + key.append(trigger.isAmbiguous()).append('|'); + key.append(nullToEmpty(trigger.getSourceState())); + return key.toString(); + } + + private static String linkResolutionKey(LinkResolution resolution) { + return resolution == null ? "" : resolution.name(); + } + + private static String matchedTransitionsKey(List matchedTransitions) { + if (matchedTransitions == null || matchedTransitions.isEmpty()) { + return ""; + } + return matchedTransitions.stream() + .filter(Objects::nonNull) + .sorted(Comparator + .comparing((MatchedTransition mt) -> nullToEmpty(mt.getEvent())) + .thenComparing(mt -> nullToEmpty(mt.getSourceState())) + .thenComparing(mt -> nullToEmpty(mt.getTargetState()))) + .map(mt -> nullToEmpty(mt.getEvent()) + ">" + + nullToEmpty(mt.getSourceState()) + ">" + + nullToEmpty(mt.getTargetState())) + .collect(Collectors.joining(",")); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java index 1f930c2..3783eac 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java @@ -243,6 +243,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { result.getMetadata().getTriggers(), result.getName(), context, stateMachineTransitions); List scopedChains = MachineScopeFilter.filterCallChainsForMachine( updatedChains, result.getName(), context, stateMachineTransitions); + scopedChains = SemanticCallChainDedup.deduplicate(scopedChains); List scopedEntryPoints = EntryPointScopeResolver.scopeFromCallChains( scopedChains, result.getMetadata().getEntryPoints()); @@ -431,11 +432,20 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM; } + /** + * Prefer a concrete trigger type; otherwise adopt the machine type only when it is also + * concrete. Non-concrete machine types ({@code String}/{@code Object}/type-vars) must not be + * stamped onto triggers — that makes multi-machine affinity treat them as ambiguous shared + * generics and fail-closed, wiping package-proven String-machine chains (e.g. MavenOrder JMS). + */ private static String concreteOrFallback(String triggerTypeFqn, String machineTypeFqn) { if (SharedServiceRoutingPolicy.isConcreteMachineType(triggerTypeFqn)) { return triggerTypeFqn; } - return machineTypeFqn; + if (SharedServiceRoutingPolicy.isConcreteMachineType(machineTypeFqn)) { + return machineTypeFqn; + } + return triggerTypeFqn; } } \ No newline at end of file diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java index 3a6b0ec..9a46e70 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java @@ -105,9 +105,17 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine { return isStringTypeOrPrimitive(smEnumType); } - // Prevent matching enum triggers to string transitions - if (!smEvent.contains(".") && triggerEvent.contains(".")) { - return false; + // Trigger scraped as String.CONST while config still stores the bare literal constant. + if (isStringTypeOrPrimitive(eventTypeFqn) && triggerEvent.contains(".") && !smEvent.contains(".")) { + String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.')); + return isStringTypeOrPrimitive(triggerEnumType); + } + + // Qualified trigger against bare SM event without eventTypeFqn: only when the qualifier + // itself is a string/primitive type (not a real enum like OrderEvent.PAY ↔ PAY). + if (eventTypeFqn == null && !smEvent.contains(".") && triggerEvent.contains(".")) { + String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.')); + return isStringTypeOrPrimitive(triggerEnumType); } if (smEvent.contains(".")) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java index b12dc45..c9eed38 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java @@ -964,6 +964,9 @@ public final class MachineEnumCanonicalizer { } if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) { + if (isStringOrPrimitiveType(enumTypeFqn)) { + return enumTypeFqn + "." + stripped; + } // Only invent enumType.CONST when CONST is a real member — otherwise JMS payload // labels like "PAY" become OrderState.PAY and break transition linking. if (context != null && !enumHasConstant(enumTypeFqn, stripped, context)) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CarrierConstraintSupport.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CarrierConstraintSupport.java index e1f5dc3..464993b 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CarrierConstraintSupport.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CarrierConstraintSupport.java @@ -66,7 +66,31 @@ public final class CarrierConstraintSupport { if (carrierBindings.isEmpty()) { return false; } - return !BooleanConstraintEvaluator.isCompatibleWithKnownBindings(constraint, carrierBindings); + String carrierConstraint = carrierOnlyConstraint(constraint, carrierBindings.keySet()); + if (carrierConstraint == null || carrierConstraint.isBlank()) { + return false; + } + return !BooleanConstraintEvaluator.isCompatibleWithKnownBindings(carrierConstraint, carrierBindings); + } + + /** + * Keeps AND-clauses whose primary identifier overlaps {@code carrierKeys}; machine-domain + * discriminators and unrelated locals are dropped so carrier binding checks are not skewed + * by if-else negations on other parameters. + */ + static String carrierOnlyConstraint(String constraint, Set carrierKeys) { + if (constraint == null || constraint.isBlank() || carrierKeys == null || carrierKeys.isEmpty()) { + return ""; + } + List kept = new ArrayList<>(); + for (String clause : ConstraintClauses.splitAndClauses(constraint)) { + String ident = ConstraintClauses.primaryIdent(clause); + if (ident == null || !containsIgnoreCase(carrierKeys, ident)) { + continue; + } + kept.add(clause.trim()); + } + return joinAndTidy(kept); } private static Set carrierPrimaryIdents(String constraint) { 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 b3fdc88..3ec2bd3 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 @@ -711,6 +711,65 @@ public class ConstructorAnalyzer { } } + /** + * Maps an inherited field supplied through {@code super(...)} to a subclass constructor parameter type. + * Returns null when the mapping cannot be proven. + */ + public String resolveSubclassConstructorParameterFqnForInheritedField( + TypeDeclaration subclassTd, + String inheritedFieldName, + click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer injectionAnalyzer) { + if (subclassTd == null || inheritedFieldName == null || inheritedFieldName.isBlank()) { + return null; + } + for (MethodDeclaration ctor : subclassTd.getMethods()) { + if (!ctor.isConstructor() || ctor.getBody() == null) { + continue; + } + int paramIdx = findAssignedParameterIndex(ctor, inheritedFieldName, context, new HashSet<>()); + if (paramIdx < 0 || paramIdx >= ctor.parameters().size()) { + continue; + } + SingleVariableDeclaration param = (SingleVariableDeclaration) ctor.parameters().get(paramIdx); + IVariableBinding paramBinding = param.resolveBinding(); + if (injectionAnalyzer != null && paramBinding != null) { + String injectedBean = injectionAnalyzer.resolveInjectedBeanFqn(paramBinding); + if (injectedBean != null && !injectedBean.isBlank()) { + return injectedBean; + } + } + if (paramBinding != null && paramBinding.getType() != null) { + String fqn = paramBinding.getType().getErasure().getQualifiedName(); + if (fqn != null && !fqn.isBlank() && !"java.lang.Object".equals(fqn)) { + return fqn; + } + } + CompilationUnit cu = subclassTd.getRoot() instanceof CompilationUnit root ? root : null; + String astFqn = resolveTypeToFqn(param.getType(), cu); + if (astFqn != null && !astFqn.isBlank()) { + return astFqn; + } + } + return null; + } + + /** Visible for unit tests verifying constructor super() parameter mapping. */ + int findAssignedParameterIndexForTest(MethodDeclaration constructorMd, String fieldName) { + return findAssignedParameterIndex(constructorMd, fieldName, context, new HashSet<>()); + } + + private String resolveTypeToFqn(Type type, CompilationUnit cu) { + if (type == null) { + return null; + } + String raw = type.toString(); + if (raw.contains("<")) { + raw = raw.substring(0, raw.indexOf('<')); + } + TypeDeclaration td = cu != null ? context.getTypeDeclaration(raw, cu) : context.getTypeDeclaration(raw); + return td != null ? context.getFqn(td) : raw; + } + private int findAssignedParameterIndex(MethodDeclaration constructorMd, String fieldName, CodebaseContext context, Set visited) { if (constructorMd == null || constructorMd.getBody() == null) return -1; if (!visited.add(constructorMd)) return -1; @@ -738,67 +797,31 @@ public class ConstructorAnalyzer { } @Override public boolean visit(ConstructorInvocation node) { - IMethodBinding resolvedBinding = node.resolveConstructorBinding(); TypeDeclaration enclosingTd = findEnclosingType(node); if (enclosingTd != null) { - for (MethodDeclaration otherMd : enclosingTd.getMethods()) { - boolean matches = false; - if (resolvedBinding != null && otherMd.resolveBinding() != null) { - matches = resolvedBinding.isEqualTo(otherMd.resolveBinding()) || resolvedBinding.getKey().equals(otherMd.resolveBinding().getKey()); - } else { - matches = otherMd.isConstructor() && otherMd != constructorMd && otherMd.parameters().size() == node.arguments().size(); - } - if (matches) { - int targetIdx = findAssignedParameterIndex(otherMd, fieldName, context, visited); - if (targetIdx >= 0 && targetIdx < node.arguments().size()) { - Expression arg = (Expression) node.arguments().get(targetIdx); - String argName = extractVariableName(arg); - if (argName != null) { - for (int i = 0; i < constructorMd.parameters().size(); i++) { - SingleVariableDeclaration svd = (SingleVariableDeclaration) constructorMd.parameters().get(i); - if (svd.getName().getIdentifier().equals(argName)) { - foundIdx[0] = i; - } - } - } - } - } - } + MethodDeclaration targetCtor = selectDelegatedConstructor( + enclosingTd.getMethods(), + node.resolveConstructorBinding(), + node.arguments().size(), + constructorMd); + mapDelegatedConstructorArgument(targetCtor, node.arguments(), fieldName, constructorMd, foundIdx, visited); } return super.visit(node); } @Override public boolean visit(SuperConstructorInvocation node) { - IMethodBinding resolvedBinding = node.resolveConstructorBinding(); TypeDeclaration enclosingTd = findEnclosingType(node); if (enclosingTd != null) { String superFqn = context.getSuperclassFqn(enclosingTd); if (superFqn != null) { TypeDeclaration superTd = context.getTypeDeclaration(superFqn); if (superTd != null) { - for (MethodDeclaration otherMd : superTd.getMethods()) { - boolean matches = false; - if (resolvedBinding != null && otherMd.resolveBinding() != null) { - matches = resolvedBinding.isEqualTo(otherMd.resolveBinding()) || resolvedBinding.getKey().equals(otherMd.resolveBinding().getKey()); - } else { - matches = otherMd.isConstructor() && otherMd.parameters().size() == node.arguments().size(); - } - if (matches) { - int targetIdx = findAssignedParameterIndex(otherMd, fieldName, context, visited); - if (targetIdx >= 0 && targetIdx < node.arguments().size()) { - Expression arg = (Expression) node.arguments().get(targetIdx); - String argName = extractVariableName(arg); - if (argName != null) { - for (int i = 0; i < constructorMd.parameters().size(); i++) { - SingleVariableDeclaration svd = (SingleVariableDeclaration) constructorMd.parameters().get(i); - if (svd.getName().getIdentifier().equals(argName)) { - foundIdx[0] = i; - } - } - } - } - } - } + MethodDeclaration targetCtor = selectDelegatedConstructor( + superTd.getMethods(), + node.resolveConstructorBinding(), + node.arguments().size(), + null); + mapDelegatedConstructorArgument(targetCtor, node.arguments(), fieldName, constructorMd, foundIdx, visited); } } } @@ -808,6 +831,97 @@ public class ConstructorAnalyzer { return foundIdx[0]; } + /** + * Prefer JDT binding identity when available. If the binding is missing or does not map to a + * source constructor, fall back to a unique same-arity constructor. Ambiguous same-arity + * overloads without a binding match return null (do not guess). + */ + private static MethodDeclaration selectDelegatedConstructor( + MethodDeclaration[] candidates, + IMethodBinding resolvedBinding, + int argumentCount, + MethodDeclaration exclude) { + if (candidates == null) { + return null; + } + if (resolvedBinding != null) { + for (MethodDeclaration otherMd : candidates) { + if (!otherMd.isConstructor() || otherMd == exclude) { + continue; + } + IMethodBinding otherBinding = otherMd.resolveBinding(); + if (otherBinding != null && constructorBindingsMatch(resolvedBinding, otherBinding)) { + return otherMd; + } + } + } + MethodDeclaration arityMatch = null; + for (MethodDeclaration otherMd : candidates) { + if (!otherMd.isConstructor() || otherMd == exclude) { + continue; + } + if (otherMd.parameters().size() != argumentCount) { + continue; + } + if (arityMatch != null) { + // Ambiguous same-arity overloads without a binding hit — do not guess. + return null; + } + arityMatch = otherMd; + } + return arityMatch; + } + + private static boolean constructorBindingsMatch(IMethodBinding left, IMethodBinding right) { + if (left.isEqualTo(right) || Objects.equals(left.getKey(), right.getKey())) { + return true; + } + if (!Objects.equals(left.getName(), right.getName())) { + return false; + } + ITypeBinding[] leftParams = left.getParameterTypes(); + ITypeBinding[] rightParams = right.getParameterTypes(); + if (leftParams.length != rightParams.length) { + return false; + } + for (int i = 0; i < leftParams.length; i++) { + if (!leftParams[i].getErasure().isEqualTo(rightParams[i].getErasure())) { + return false; + } + } + ITypeBinding leftDecl = left.getDeclaringClass(); + ITypeBinding rightDecl = right.getDeclaringClass(); + return leftDecl != null && rightDecl != null && leftDecl.getErasure().isEqualTo(rightDecl.getErasure()); + } + + private void mapDelegatedConstructorArgument( + MethodDeclaration targetCtor, + List arguments, + String fieldName, + MethodDeclaration constructorMd, + int[] foundIdx, + Set visited) { + if (targetCtor == null || arguments == null) { + return; + } + int targetIdx = findAssignedParameterIndex(targetCtor, fieldName, context, visited); + if (targetIdx < 0 || targetIdx >= arguments.size()) { + return; + } + Expression arg = (Expression) arguments.get(targetIdx); + String argName = extractVariableName(arg); + if (argName == null) { + return; + } + for (int i = 0; i < constructorMd.parameters().size(); i++) { + SingleVariableDeclaration svd = (SingleVariableDeclaration) constructorMd.parameters().get(i); + if (svd.getName().getIdentifier().equals(argName)) { + foundIdx[0] = i; + return; + } + } + } + private String extractVariableName(Expression expr) { if (expr instanceof SimpleName sn) return sn.getIdentifier(); if (expr instanceof FieldAccess fa) return fa.getName().getIdentifier(); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorInjectionTypeNarrower.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorInjectionTypeNarrower.java new file mode 100644 index 0000000..bf2f87b --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorInjectionTypeNarrower.java @@ -0,0 +1,94 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.ITypeBinding; +import org.eclipse.jdt.core.dom.IVariableBinding; +import org.eclipse.jdt.core.dom.TypeDeclaration; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Narrows generic superclass collaborator fields to the concrete type supplied by a subclass + * constructor via {@code super(...)} delegation. + */ +public final class ConstructorInjectionTypeNarrower { + + private ConstructorInjectionTypeNarrower() { + } + + public static String resolveConcreteReceiverTypeFqn( + IVariableBinding fieldBinding, + TypeDeclaration callSiteType, + CodebaseContext context, + InjectionPointAnalyzer injectionAnalyzer, + ConstructorAnalyzer constructorAnalyzer) { + if (fieldBinding == null + || !fieldBinding.isField() + || context == null + || constructorAnalyzer == null + || fieldBinding.getDeclaringClass() == null) { + return null; + } + + String fieldName = fieldBinding.getName(); + String declaringFqn = fieldBinding.getDeclaringClass().getErasure().getQualifiedName(); + if (declaringFqn == null || declaringFqn.isBlank() || fieldName == null || fieldName.isBlank()) { + return null; + } + + Set concreteTypes = new LinkedHashSet<>(); + for (String candidateFqn : candidateSubclassFqns(callSiteType, declaringFqn, context)) { + TypeDeclaration candidateTd = context.getTypeDeclaration(candidateFqn); + if (candidateTd == null || context.isSubtypeOrSame(candidateFqn, declaringFqn) == false) { + continue; + } + if (candidateTd.isInterface() || org.eclipse.jdt.core.dom.Modifier.isAbstract(candidateTd.getModifiers())) { + continue; + } + String narrowed = constructorAnalyzer.resolveSubclassConstructorParameterFqnForInheritedField( + candidateTd, fieldName, injectionAnalyzer); + if (narrowed != null && !narrowed.isBlank()) { + concreteTypes.add(narrowed); + } + } + + return concreteTypes.size() == 1 ? concreteTypes.iterator().next() : null; + } + + private static Set candidateSubclassFqns( + TypeDeclaration callSiteType, + String declaringFqn, + CodebaseContext context) { + Set candidates = new LinkedHashSet<>(); + if (callSiteType != null) { + String callSiteFqn = context.getFqn(callSiteType); + if (callSiteFqn != null && !callSiteFqn.isBlank() && !callSiteFqn.equals(declaringFqn)) { + candidates.add(callSiteFqn); + } + } + List implementations = context.getImplementations(declaringFqn); + if (implementations != null) { + candidates.addAll(implementations); + } + return candidates; + } + + public static String resolveConcreteReceiverTypeFqnFromBinding( + IVariableBinding fieldBinding, + ITypeBinding callSiteTypeBinding, + CodebaseContext context, + InjectionPointAnalyzer injectionAnalyzer, + ConstructorAnalyzer constructorAnalyzer) { + if (callSiteTypeBinding == null) { + return resolveConcreteReceiverTypeFqn( + fieldBinding, null, context, injectionAnalyzer, constructorAnalyzer); + } + String callSiteFqn = callSiteTypeBinding.getErasure().getQualifiedName(); + TypeDeclaration callSiteTd = context != null ? context.getTypeDeclaration(callSiteFqn) : null; + return resolveConcreteReceiverTypeFqn( + fieldBinding, callSiteTd, context, injectionAnalyzer, constructorAnalyzer); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EnrichmentService.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EnrichmentService.java index b3f3f37..d367c6c 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EnrichmentService.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EnrichmentService.java @@ -3,17 +3,12 @@ package click.kamil.springstatemachineexporter.analysis.service; import click.kamil.springstatemachineexporter.analysis.enricher.AnalysisEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher; import click.kamil.springstatemachineexporter.analysis.model.CallChain; -import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher; +import click.kamil.springstatemachineexporter.analysis.enricher.SemanticCallChainDedup; import click.kamil.springstatemachineexporter.analysis.enricher.path.ForwardPathEstimationEnricher; import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; @@ -83,7 +78,10 @@ public class EnrichmentService { if (result.getMetadata() != null && context != null && intelligence != null) { List refreshed = CallChainEnricher.buildCallChainsForMachine(result, context, intelligence); if (refreshed != null) { - List merged = mergeCallChainsByEntryPoint(result.getMetadata().getCallChains(), refreshed); + List merged = SemanticCallChainDedup.mergeRefresh( + result.getMetadata().getCallChains(), + refreshed, + SemanticCallChainDedup.KeepStrategy.SHORTEST); result.setMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder() .triggers(result.getMetadata().getTriggers()) .entryPoints(result.getMetadata().getEntryPoints()) @@ -95,47 +93,6 @@ public class EnrichmentService { relinkAfterPropertyResolution(result, context, intelligence); } - private static List mergeCallChainsByEntryPoint( - List existing, - List refreshed) { - Map byEntryPoint = new LinkedHashMap<>(); - if (existing != null) { - for (CallChain chain : existing) { - byEntryPoint.putIfAbsent(callChainKey(chain), chain); - } - } - for (CallChain chain : refreshed) { - byEntryPoint.put(callChainKey(chain), chain); - } - return new ArrayList<>(byEntryPoint.values()); - } - - private static String callChainKey(CallChain chain) { - if (chain == null) { - return ""; - } - String entryPointKey = entryPointKey(chain.getEntryPoint()); - if (!entryPointKey.isBlank()) { - return entryPointKey; - } - if (chain.getTriggerPoint() != null - && chain.getTriggerPoint().getClassName() != null - && chain.getTriggerPoint().getMethodName() != null) { - return chain.getTriggerPoint().getClassName() + "#" + chain.getTriggerPoint().getMethodName(); - } - return ""; - } - - private static String entryPointKey(EntryPoint entryPoint) { - if (entryPoint == null) { - return ""; - } - if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) { - return entryPoint.getName(); - } - return entryPoint.getClassName() + "#" + entryPoint.getMethodName(); - } - private static void runEnrichers( List enrichers, AnalysisResult result, 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 584dd04..3853aa2 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 @@ -79,6 +79,14 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { log.debug("CALLGRAPH RESOLVING BEAN FOR BINDING: {} calling {}", varBinding.getName(), methodName); } String concreteFqn = injectionAnalyzer.resolveInjectedBeanFqn(varBinding); + if (concreteFqn == null) { + concreteFqn = ConstructorInjectionTypeNarrower.resolveConcreteReceiverTypeFqn( + varBinding, + enclosingType, + context, + injectionAnalyzer, + constructorAnalyzer); + } if (concreteFqn != null) { if (log.isDebugEnabled()) { log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn); @@ -153,7 +161,17 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { if (!(nameBinding instanceof IVariableBinding varBinding)) { return null; } - return injectionAnalyzer.resolveInjectedBeanFqn(varBinding); + String direct = injectionAnalyzer.resolveInjectedBeanFqn(varBinding); + if (direct != null) { + return direct; + } + TypeDeclaration enclosingType = findEnclosingType(receiver); + return ConstructorInjectionTypeNarrower.resolveConcreteReceiverTypeFqn( + varBinding, + enclosingType, + context, + injectionAnalyzer, + constructorAnalyzer); } private Expression unwrapMessageBuilder(Expression expr) { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/SemanticCallChainDedupTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/SemanticCallChainDedupTest.java new file mode 100644 index 0000000..18d7e89 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/SemanticCallChainDedupTest.java @@ -0,0 +1,386 @@ +package click.kamil.springstatemachineexporter.analysis.enricher; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; +import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SemanticCallChainDedupTest { + + @Test + void shouldPreferEntryAnchoredPathOverShorterOrphanPath() { + EntryPoint entryPoint = EntryPoint.builder() + .type(EntryPoint.Type.JMS) + .name("JMS: order.queue") + .className("com.example.JmsOrderListener") + .methodName("onMessage") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("ORDER_EVENT") + .build(); + List matched = List.of(MatchedTransition.builder() + .event("ORDER_EVENT") + .sourceState("BUSY") + .targetState("DONE") + .build()); + + CallChain anchoredLonger = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(matched) + .methodChain(List.of( + "com.example.JmsOrderListener.onMessage", + "com.example.OrderService.onMessage", + "com.example.StateMachine.sendEvent")) + .build(); + CallChain orphanShorter = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(matched) + .methodChain(List.of("com.example.OrderService.onMessage")) + .build(); + + List deduped = SemanticCallChainDedup.deduplicate(List.of(orphanShorter, anchoredLonger)); + + assertThat(deduped).hasSize(1); + assertThat(deduped.get(0).getMethodChain()) + .containsExactly( + "com.example.JmsOrderListener.onMessage", + "com.example.OrderService.onMessage", + "com.example.StateMachine.sendEvent"); + } + + @Test + void shouldCollapseChainsWithSameSemanticKeyButDifferentPaths() { + EntryPoint entryPoint = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/order/pay") + .className("com.example.Api") + .methodName("pay") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("PAY") + .build(); + List matched = List.of(MatchedTransition.builder() + .event("PAY") + .sourceState("NEW") + .targetState("PAID") + .build()); + + CallChain longPath = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(matched) + .methodChain(List.of("a", "b", "c", "d")) + .build(); + CallChain shortPath = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(matched) + .methodChain(List.of("a", "b")) + .build(); + + List deduped = SemanticCallChainDedup.deduplicate(List.of(longPath, shortPath)); + + assertThat(deduped).hasSize(1); + assertThat(deduped.get(0).getMethodChain()).containsExactly("a", "b"); + } + + @Test + void shouldKeepDistinctChainsWhenMatchedTransitionsDiffer() { + EntryPoint entryPoint = EntryPoint.builder() + .name("POST /api/order") + .className("com.example.Api") + .methodName("handle") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("PAY") + .build(); + + CallChain payChain = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(List.of(MatchedTransition.builder() + .event("PAY") + .sourceState("NEW") + .targetState("PAID") + .build())) + .methodChain(List.of("hop1")) + .build(); + CallChain shipChain = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(List.of(MatchedTransition.builder() + .event("SHIP") + .sourceState("PAID") + .targetState("SHIPPED") + .build())) + .methodChain(List.of("hop1")) + .build(); + + List deduped = SemanticCallChainDedup.deduplicate(List.of(payChain, shipChain)); + + assertThat(deduped).hasSize(2); + } + + @Test + void firstStrategyShouldKeepFirstSeenChain() { + EntryPoint entryPoint = EntryPoint.builder() + .name("JMS: orders") + .className("com.example.Listener") + .methodName("onMessage") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("PAY") + .build(); + List matched = List.of(MatchedTransition.builder() + .event("PAY") + .sourceState("NEW") + .targetState("PAID") + .build()); + + CallChain first = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(matched) + .methodChain(List.of("long", "path")) + .build(); + CallChain second = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(matched) + .methodChain(List.of("short")) + .build(); + + List deduped = SemanticCallChainDedup.deduplicate( + List.of(first, second), + SemanticCallChainDedup.KeepStrategy.FIRST); + + assertThat(deduped).hasSize(1); + assertThat(deduped.get(0).getMethodChain()).containsExactly("long", "path"); + } + + @Test + void mergeRefreshShouldReplaceExistingChainForSameEntryPoint() { + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.web.OrderController") + .methodName("pay") + .build(); + CallChain existing = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(TriggerPoint.builder() + .className("com.example.web.OrderStateMachine") + .methodName("sendEvent") + .event("event") + .build()) + .methodChain(null) + .build(); + CallChain refreshed = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(TriggerPoint.builder() + .className("com.example.web.OrderController") + .methodName("pay") + .event("PAY") + .build()) + .methodChain(List.of("com.example.web.OrderController.pay")) + .build(); + + List merged = SemanticCallChainDedup.mergeRefresh( + List.of(existing), + List.of(refreshed), + SemanticCallChainDedup.KeepStrategy.SHORTEST); + + assertThat(merged).hasSize(1); + assertThat(merged.get(0).getMethodChain()).containsExactly("com.example.web.OrderController.pay"); + assertThat(merged.get(0).getTriggerPoint().getEvent()).isEqualTo("PAY"); + } + + @Test + void mergeRefreshShouldReplaceSameEntryAndTriggerEvent() { + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.web.OrderController") + .methodName("pay") + .build(); + CallChain existing = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(TriggerPoint.builder() + .className("com.example.web.OrderStateMachine") + .methodName("sendEvent") + .event("PAY") + .build()) + .methodChain(null) + .build(); + CallChain refreshed = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(TriggerPoint.builder() + .className("com.example.web.OrderController") + .methodName("pay") + .event("PAY") + .build()) + .methodChain(List.of("com.example.web.OrderController.pay")) + .build(); + + List merged = SemanticCallChainDedup.mergeRefresh( + List.of(existing), + List.of(refreshed), + SemanticCallChainDedup.KeepStrategy.SHORTEST); + + assertThat(merged).hasSize(1); + assertThat(merged.get(0).getMethodChain()).containsExactly("com.example.web.OrderController.pay"); + assertThat(merged.get(0).getTriggerPoint().getMethodName()).isEqualTo("pay"); + } + + @Test + void mergeRefreshShouldKeepMultiEventFanOutFromSameEntryPoint() { + EntryPoint entryPoint = EntryPoint.builder() + .name("POST /api/order") + .className("com.example.Api") + .methodName("handle") + .build(); + CallChain existingPay = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("PAY") + .build()) + .methodChain(List.of("com.example.Api.handle", "pay")) + .build(); + CallChain refreshedShip = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("SHIP") + .build()) + .methodChain(List.of("com.example.Api.handle", "ship")) + .build(); + CallChain refreshedPay = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("PAY") + .build()) + .methodChain(List.of("com.example.Api.handle")) + .build(); + + List merged = SemanticCallChainDedup.mergeRefresh( + List.of(existingPay), + List.of(refreshedShip, refreshedPay), + SemanticCallChainDedup.KeepStrategy.SHORTEST); + + assertThat(merged).hasSize(2); + assertThat(merged) + .anySatisfy(chain -> { + assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("PAY"); + assertThat(chain.getMethodChain()).containsExactly("com.example.Api.handle"); + }); + assertThat(merged) + .anySatisfy(chain -> assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("SHIP")); + } + + @Test + void mergeShouldCollapseJsonStubChainWithRefreshedEntryPoint() { + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.web.OrderController") + .methodName("pay") + .event("PAY") + .sourceState("NEW") + .build(); + List matched = List.of(MatchedTransition.builder() + .event("PAY") + .sourceState("NEW") + .targetState("PAID") + .build()); + + CallChain jsonStub = CallChain.builder() + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(matched) + .build(); + CallChain refreshed = CallChain.builder() + .entryPoint(EntryPoint.builder() + .className("com.example.web.OrderController") + .methodName("pay") + .build()) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(matched) + .methodChain(List.of("com.example.web.OrderController.pay")) + .build(); + + List merged = SemanticCallChainDedup.merge( + List.of(jsonStub), + List.of(refreshed), + SemanticCallChainDedup.KeepStrategy.SHORTEST); + + assertThat(merged).hasSize(1); + assertThat(merged.get(0).getEntryPoint()).isNotNull(); + assertThat(merged.get(0).getMethodChain()).containsExactly("com.example.web.OrderController.pay"); + } + + @Test + void mergeShouldSemanticDedupAcrossExistingAndRefreshedLists() { + EntryPoint entryPoint = EntryPoint.builder() + .name("POST /api/order/pay") + .className("com.example.Api") + .methodName("pay") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("PAY") + .build(); + List matched = List.of(MatchedTransition.builder() + .event("PAY") + .sourceState("NEW") + .targetState("PAID") + .build()); + + CallChain existing = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(matched) + .methodChain(List.of("a", "b", "c")) + .build(); + CallChain refreshed = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(matched) + .methodChain(List.of("a")) + .build(); + + List merged = SemanticCallChainDedup.merge( + List.of(existing), + List.of(refreshed), + SemanticCallChainDedup.KeepStrategy.SHORTEST); + + assertThat(merged).hasSize(1); + assertThat(merged.get(0).getMethodChain()).containsExactly("a"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java index 797b865..9a2fd89 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java @@ -1028,6 +1028,102 @@ class TransitionLinkerEnricherTest { assertThat(resultUser.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); } + @Test + void shouldDeduplicateSemanticallyEquivalentChainsKeepingShortestPath() { + Transition payT = new Transition(); + payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW"))); + payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID"))); + payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY")); + + EntryPoint entryPoint = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/order/pay") + .className("com.example.Api") + .methodName("pay") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .event("OrderEvent.PAY") + .polymorphicEvents(List.of("OrderEvent.PAY")) + .build(); + + CallChain longPath = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .methodChain(List.of( + "com.example.Api.pay", + "com.example.OrderService.route", + "com.example.OrderService.delegate", + "com.example.StateMachine.sendEvent")) + .build(); + CallChain shortPath = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .methodChain(List.of( + "com.example.Api.pay", + "com.example.StateMachine.sendEvent")) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("OrderStateMachineConfig") + .transitions(List.of(payT)) + .metadata(CodebaseMetadata.builder().callChains(List.of(longPath, shortPath)).build()) + .build(); + + enricher.enrich(result, null, null); + + assertThat(result.getMetadata().getCallChains()).hasSize(1); + assertThat(result.getMetadata().getCallChains().get(0).getMethodChain()) + .containsExactly( + "com.example.Api.pay", + "com.example.StateMachine.sendEvent"); + assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); + } + + @Test + void shouldNotStampNonConcreteStringMachineTypeOntoTrigger() { + Transition orderEvent = new Transition(); + orderEvent.setSourceStates(List.of(State.of("BUSY", "String.BUSY"))); + orderEvent.setTargetStates(List.of(State.of("DONE", "String.DONE"))); + orderEvent.setEvent(Event.of("ORDER_EVENT", "String.ORDER_EVENT")); + + CallChain chain = CallChain.builder() + .entryPoint(EntryPoint.builder() + .type(EntryPoint.Type.JMS) + .name("JMS: order.queue") + .className("click.kamil.maven.api.JmsOrderListener") + .methodName("onMessage") + .build()) + .triggerPoint(TriggerPoint.builder() + .event("ORDER_EVENT") + .polymorphicEvents(List.of("ORDER_EVENT")) + .build()) + .methodChain(List.of( + "click.kamil.maven.core.MavenOrderStateMachine.OrderService.onMessage")) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("click.kamil.maven.core.MavenOrderStateMachine") + .eventTypeFqn("String") + .stateTypeFqn("String") + .transitions(List.of(orderEvent)) + .metadata(CodebaseMetadata.builder() + .entryPoints(List.of(chain.getEntryPoint())) + .callChains(List.of(chain)) + .build()) + .build(); + + enricher.enrich(result, null, null); + + assertThat(result.getMetadata().getCallChains()).isNotEmpty(); + TriggerPoint linked = result.getMetadata().getCallChains().get(0).getTriggerPoint(); + assertThat(linked.getEventTypeFqn()) + .as("non-concrete machine String must not be stamped onto the trigger") + .isNull(); + assertThat(linked.getStateTypeFqn()).isNull(); + assertThat(result.getMetadata().getCallChains().get(0).getLinkResolution()) + .isEqualTo(LinkResolution.RESOLVED); + } + @Test void shouldRejectChainWhenDispatcherDomainConstraintDoesNotMatchMachine() { Transition payT = new Transition(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngineTest.java index 9b9cf26..572bb72 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngineTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngineTest.java @@ -142,6 +142,49 @@ class StrictFqnMatchingEngineTest { assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); } + @Test + void shouldMatchQualifiedStringTriggerToBareConfiguredStringTransition() { + Event smEvent = Event.of("\"INHERITED_SUBMIT\"", "INHERITED_SUBMIT"); + TriggerPoint triggerPoint = TriggerPoint.builder() + .event("String.INHERITED_SUBMIT") + .eventTypeFqn("String") + .polymorphicEvents(List.of("String.INHERITED_SUBMIT")) + .build(); + + assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); + } + + @Test + void shouldMatchQualifiedStringTriggerToBareWhenEventTypeFqnMissing() { + Event smEvent = Event.of("ORDER_EVENT", "ORDER_EVENT"); + TriggerPoint triggerPoint = TriggerPoint.builder() + .event("String.ORDER_EVENT") + .build(); + + assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); + } + + @Test + void shouldNotMatchEnumQualifiedTriggerToBareConstant() { + Event smEvent = Event.of("PAY", "PAY"); + TriggerPoint triggerPoint = TriggerPoint.builder() + .event("OrderEvent.PAY") + .eventTypeFqn("com.example.OrderEvent") + .build(); + + assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); + } + + @Test + void shouldNotMatchEnumQualifiedTriggerToBareWhenEventTypeFqnMissing() { + Event smEvent = Event.of("PAY", "PAY"); + TriggerPoint triggerPoint = TriggerPoint.builder() + .event("OrderEvent.PAY") + .build(); + + assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); + } + @Test void shouldMatchCanonicalStringLiteralTriggerToConfiguredStringTransition() { Event smEvent = Event.of("\"AUDIT_EVENT\"", "String.AUDIT_EVENT"); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CarrierConstraintSupportTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CarrierConstraintSupportTest.java index 42f9c01..37d875f 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CarrierConstraintSupportTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CarrierConstraintSupportTest.java @@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.resolver.ConstraintClause import org.junit.jupiter.api.Test; import java.util.Map; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -63,4 +64,30 @@ class CarrierConstraintSupportTest { "command == ORDER_PAY", Map.of("commandKey", "order.pay"))).isFalse(); } + + @Test + void carrierOnlyConstraintIgnoresMachineDomainNegations() { + String constraint = "\"DOCUMENT\".equalsIgnoreCase(machineType) && \"SUBMIT\".equalsIgnoreCase(event) " + + "&& !(\"ORDER\".equalsIgnoreCase(machineType))"; + assertThat(CarrierConstraintSupport.carrierOnlyConstraint(constraint, Set.of("event"))) + .isEqualTo("\"SUBMIT\".equalsIgnoreCase(event)"); + } + + @Test + void dispatcherDocumentBranchDoesNotContradictEventBinding() { + String constraint = "\"DOCUMENT\".equalsIgnoreCase(machineType) && \"SUBMIT\".equalsIgnoreCase(event) " + + "&& !(\"ORDER\".equalsIgnoreCase(machineType)) && \"DOCUMENT\".equalsIgnoreCase(machineType)"; + assertThat(CarrierConstraintSupport.contradictsCarrierBindings( + constraint, Map.of("event", "SUBMIT", "eventString", "event", "machineType", "DOCUMENT"))) + .isFalse(); + } + + @Test + void dispatcherOrderBranchDoesNotContradictEventBinding() { + String constraint = "\"ORDER\".equalsIgnoreCase(machineType) && \"PAY\".equalsIgnoreCase(event) " + + "&& \"ORDER\".equalsIgnoreCase(machineType)"; + assertThat(CarrierConstraintSupport.contradictsCarrierBindings( + constraint, Map.of("event", "PAY", "eventString", "event", "machineType", "ORDER"))) + .isFalse(); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorInjectionTypeNarrowerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorInjectionTypeNarrowerTest.java new file mode 100644 index 0000000..55647f3 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorInjectionTypeNarrowerTest.java @@ -0,0 +1,152 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer; +import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry; +import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner; +import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.FieldDeclaration; +import org.eclipse.jdt.core.dom.IVariableBinding; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +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.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConstructorInjectionTypeNarrowerTest { + + @TempDir + Path tempDir; + + private CodebaseContext context; + private InjectionPointAnalyzer injectionAnalyzer; + private ConstructorAnalyzer constructorAnalyzer; + + @BeforeEach + void setUp() throws Exception { + Files.writeString(tempDir.resolve("SuperInjection.java"), """ + package com.example; + + import org.springframework.stereotype.Service; + + interface CollaboratorService { + void processEvent(String event); + } + + @Service + class MosService implements CollaboratorService { + void processEvent(String event) {} + } + + abstract class BaseStateMachineService { + protected final T service; + + BaseStateMachineService(T service) { + this.service = service; + } + + void handle(String event) { + service.processEvent(event); + } + } + + @Service + class MosStateMachineService extends BaseStateMachineService { + MosStateMachineService(MosService mosService) { + super(mosService); + } + } + """); + + context = new CodebaseContext(); + context.setResolveBindings(true); + context.setClasspath(ToolingClasspath.currentJvmJarEntries()); + context.scan(Set.of(tempDir), Set.of()); + + SpringBeanRegistry registry = new SpringBeanRegistry(); + for (var cu : context.getCompilationUnits()) { + cu.accept(new SpringContextScanner(registry)); + } + injectionAnalyzer = new InjectionPointAnalyzer(new SpringDependencyResolver(registry)); + constructorAnalyzer = new ConstructorAnalyzer(context, null); + } + + @Test + void shouldIndexSubclassOfGenericBase() { + List impls = context.getImplementations("com.example.BaseStateMachineService"); + assertThat(impls).contains("com.example.MosStateMachineService"); + } + + @Test + void shouldMapSuperConstructorParameterIndexForInheritedField() { + TypeDeclaration subclass = context.getTypeDeclaration("com.example.MosStateMachineService"); + TypeDeclaration base = context.getTypeDeclaration("com.example.BaseStateMachineService"); + assertThat(context.getSuperclassFqn(subclass)).isEqualTo("com.example.BaseStateMachineService"); + + MethodDeclaration baseCtor = null; + for (MethodDeclaration md : base.getMethods()) { + if (md.isConstructor()) { + baseCtor = md; + break; + } + } + assertThat(baseCtor).isNotNull(); + assertThat(constructorAnalyzer.findAssignedParameterIndexForTest(baseCtor, "service")).isEqualTo(0); + + MethodDeclaration ctor = null; + for (MethodDeclaration md : subclass.getMethods()) { + if (md.isConstructor()) { + ctor = md; + break; + } + } + assertThat(ctor).isNotNull(); + int idx = constructorAnalyzer.findAssignedParameterIndexForTest(ctor, "service"); + assertThat(idx).isEqualTo(0); + } + + @Test + void shouldMapInheritedFieldToSubclassConstructorParameter() { + TypeDeclaration subclass = context.getTypeDeclaration("com.example.MosStateMachineService"); + String narrowed = constructorAnalyzer.resolveSubclassConstructorParameterFqnForInheritedField( + subclass, "service", injectionAnalyzer); + assertThat(narrowed).isEqualTo("com.example.MosService"); + } + + @Test + void shouldNarrowFieldBindingInBaseMethod() { + TypeDeclaration base = context.getTypeDeclaration("com.example.BaseStateMachineService"); + MethodDeclaration handle = context.findMethodDeclaration(base, "handle", false); + assertThat(handle).isNotNull(); + + final IVariableBinding[] fieldBinding = {null}; + handle.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if ("processEvent".equals(node.getName().getIdentifier()) && node.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName sn) { + if (sn.resolveBinding() instanceof IVariableBinding vb) { + fieldBinding[0] = vb; + } + } + return super.visit(node); + } + }); + + assertThat(fieldBinding[0]).isNotNull(); + String narrowed = ConstructorInjectionTypeNarrower.resolveConcreteReceiverTypeFqn( + fieldBinding[0], + base, + context, + injectionAnalyzer, + constructorAnalyzer); + assertThat(narrowed).isEqualTo("com.example.MosService"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorSuperInjectionNarrowingTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorSuperInjectionNarrowingTest.java new file mode 100644 index 0000000..7ebc819 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorSuperInjectionNarrowingTest.java @@ -0,0 +1,239 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer; +import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry; +import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner; +import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.BeforeEach; +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 java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression for generic superclass fields supplied via subclass {@code super(concreteService)}. + */ +class ConstructorSuperInjectionNarrowingTest { + + @TempDir + Path tempDir; + + private CodebaseContext context; + private JdtCallGraphEngine engine; + + @BeforeEach + void setUp() throws Exception { + writeFixture(tempDir); + + context = new CodebaseContext(); + context.setResolveBindings(true); + context.setClasspath(ToolingClasspath.currentJvmJarEntries()); + context.scan(Set.of(tempDir), Set.of()); + + SpringBeanRegistry registry = new SpringBeanRegistry(); + for (var cu : context.getCompilationUnits()) { + cu.accept(new SpringContextScanner(registry)); + } + InjectionPointAnalyzer injectionAnalyzer = + new InjectionPointAnalyzer(new SpringDependencyResolver(registry)); + engine = new JdtCallGraphEngine(context, injectionAnalyzer); + } + + @Test + void shouldNarrowGenericSuperFieldToConcreteSubclassInjection() { + Map> graph = engine.buildCallGraph(); + + List fromShared = graph.get("com.example.BaseStateMachineService.handle"); + assertThat(fromShared) + .as("superclass method must resolve collaborator via subclass ctor injection") + .isNotEmpty(); + assertThat(fromShared) + .extracting(CallEdge::getTargetMethod) + .contains("com.example.MosService.processEvent"); + assertThat(fromShared) + .extracting(CallEdge::getReceiverTypeFqn) + .containsOnly("com.example.MosService"); + assertThat(fromShared) + .extracting(CallEdge::getTargetMethod) + .noneMatch(target -> target.contains("OtherService")); + } + + @Test + void shouldProduceSingleChainWithoutSiblingServiceFanOut() { + List chains = engine.findChains( + List.of(EntryPoint.builder() + .type(EntryPoint.Type.CUSTOM) + .className("com.example.MosStateMachineService") + .methodName("dispatch") + .build()), + List.of(TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("MOS_EVENT") + .build())); + + assertThat(chains).isNotEmpty(); + assertThat(chains) + .allSatisfy(chain -> assertThat(chain.getMethodChain()) + .noneMatch(hop -> hop.contains("OtherService"))); + assertThat(chains) + .anyMatch(chain -> chain.getMethodChain().stream() + .anyMatch(hop -> hop.contains("MosService"))); + } + + private static void writeFixture(Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("SuperInjection.java"), """ + package com.example; + + import org.springframework.stereotype.Service; + + interface CollaboratorService { + void processEvent(String event); + } + + @Service + class MosService implements CollaboratorService { + private final StateMachine sm = new StateMachine(); + @Override + public void processEvent(String event) { + sm.sendEvent("MOS_EVENT"); + } + } + + @Service + class OtherService implements CollaboratorService { + private final StateMachine sm = new StateMachine(); + @Override + public void processEvent(String event) { + sm.sendEvent("OTHER_EVENT"); + } + } + + abstract class BaseStateMachineService { + protected final T service; + + BaseStateMachineService(T service) { + this.service = service; + } + + void handle(String event) { + service.processEvent(event); + } + } + + @Service + class MosStateMachineService extends BaseStateMachineService { + MosStateMachineService(MosService mosService) { + super(mosService); + } + + void dispatch(String event) { + handle(event); + } + } + + class StateMachine { + void sendEvent(Object event) {} + } + """); + } + + @Test + void shouldPreferBindingOverSameAritySuperOverload() throws Exception { + Path overloadDir = tempDir.resolve("overload"); + Files.createDirectories(overloadDir); + Files.writeString(overloadDir.resolve("OverloadSuper.java"), """ + package com.example.overload; + + import org.springframework.stereotype.Service; + + interface CollaboratorService { + void processEvent(String event); + } + + @Service + class MosService implements CollaboratorService { + private final StateMachine sm = new StateMachine(); + @Override + public void processEvent(String event) { + sm.sendEvent("MOS_EVENT"); + } + } + + @Service + class OtherService implements CollaboratorService { + private final StateMachine sm = new StateMachine(); + @Override + public void processEvent(String event) { + sm.sendEvent("OTHER_EVENT"); + } + } + + abstract class BaseStateMachineService { + protected final CollaboratorService service; + + // Same arity; service is param 0 here. + BaseStateMachineService(CollaboratorService service, String unused) { + this.service = service; + } + + // Same arity; service is param 1 here — must not win when binding resolves to the other ctor. + BaseStateMachineService(String unused, CollaboratorService service) { + this.service = service; + } + + void handle(String event) { + service.processEvent(event); + } + } + + @Service + class MosStateMachineService extends BaseStateMachineService { + MosStateMachineService(MosService mosService, String label) { + super(mosService, label); + } + + void dispatch(String event) { + handle(event); + } + } + + class StateMachine { + void sendEvent(Object event) {} + } + """); + + CodebaseContext overloadContext = new CodebaseContext(); + overloadContext.setResolveBindings(true); + overloadContext.setClasspath(ToolingClasspath.currentJvmJarEntries()); + overloadContext.scan(Set.of(overloadDir), Set.of()); + + SpringBeanRegistry registry = new SpringBeanRegistry(); + for (var cu : overloadContext.getCompilationUnits()) { + cu.accept(new SpringContextScanner(registry)); + } + InjectionPointAnalyzer injectionAnalyzer = + new InjectionPointAnalyzer(new SpringDependencyResolver(registry)); + JdtCallGraphEngine overloadEngine = new JdtCallGraphEngine(overloadContext, injectionAnalyzer); + + Map> graph = overloadEngine.buildCallGraph(); + List fromShared = graph.get("com.example.overload.BaseStateMachineService.handle"); + assertThat(fromShared) + .extracting(CallEdge::getReceiverTypeFqn) + .containsOnly("com.example.overload.MosService"); + assertThat(fromShared) + .extracting(CallEdge::getTargetMethod) + .noneMatch(target -> target.contains("OtherService")); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnrichmentServiceSemanticMergeTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnrichmentServiceSemanticMergeTest.java new file mode 100644 index 0000000..a265840 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnrichmentServiceSemanticMergeTest.java @@ -0,0 +1,78 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.enricher.SemanticCallChainDedup; +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; +import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.model.Event; +import click.kamil.springstatemachineexporter.model.State; +import click.kamil.springstatemachineexporter.model.Transition; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class EnrichmentServiceSemanticMergeTest { + + @Test + void refreshMergeShouldUseSemanticDedupNotEntryPointOnly() { + EntryPoint entryPoint = EntryPoint.builder() + .name("POST /api/order/pay") + .className("com.example.Api") + .methodName("pay") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .event("PAY") + .build(); + List matched = List.of(MatchedTransition.builder() + .event("PAY") + .sourceState("NEW") + .targetState("PAID") + .build()); + + CallChain existing = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(matched) + .methodChain(List.of("a", "b", "c")) + .build(); + CallChain refreshed = CallChain.builder() + .entryPoint(entryPoint) + .triggerPoint(trigger) + .linkResolution(LinkResolution.RESOLVED) + .matchedTransitions(matched) + .methodChain(List.of("a")) + .build(); + + List merged = SemanticCallChainDedup.mergeRefresh( + List.of(existing), + List.of(refreshed), + SemanticCallChainDedup.KeepStrategy.SHORTEST); + + assertThat(merged).hasSize(1); + assertThat(merged.get(0).getMethodChain()).containsExactly("a"); + + Transition payT = new Transition(); + payT.setSourceStates(List.of(State.of("NEW", "NEW"))); + payT.setTargetStates(List.of(State.of("PAID", "PAID"))); + payT.setEvent(Event.of("PAY", "PAY")); + + AnalysisResult result = AnalysisResult.builder() + .name("OrderStateMachineConfig") + .transitions(List.of(payT)) + .metadata(CodebaseMetadata.builder().callChains(merged).build()) + .build(); + + new EnrichmentService(List.of()).relinkAfterPropertyResolution(result, null, null); + + assertThat(result.getMetadata().getCallChains()).hasSize(1); + assertThat(result.getMetadata().getCallChains().get(0).getMethodChain()).containsExactly("a"); + assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MavenOrderStringMachineLinkageRegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MavenOrderStringMachineLinkageRegressionTest.java new file mode 100644 index 0000000..6747945 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MavenOrderStringMachineLinkageRegressionTest.java @@ -0,0 +1,82 @@ +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 java.util.stream.StreamSupport; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * String-typed machines with an abstract ConfigurerAdapter base must keep package-proven + * JMS/REST chains. Linker must not stamp non-concrete {@code String} type FQNs onto triggers + * and then fail-closed as ambiguous shared generics. + */ +class MavenOrderStringMachineLinkageRegressionTest { + + @Test + void coreModuleExportRetainsJmsAndRestResolvedChains(@TempDir Path tempDir) throws Exception { + Path coreModule = findProjectRoot().resolve("state_machines/maven_multi_module/core-module"); + ExportService exportService = new ExportService(List.of(new JsonExporter())); + exportService.runExporter(coreModule, tempDir, List.of("json"), true, List.of(), null, null, + click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, + click.kamil.springstatemachineexporter.exporter.EnumFormat.fn); + + JsonNode json = readMachineJson(tempDir, "MavenOrderStateMachine", new ObjectMapper()); + JsonNode chains = json.path("metadata").path("callChains"); + JsonNode entryPoints = json.path("metadata").path("entryPoints"); + + assertThat(chains.isArray()).isTrue(); + assertThat(chains.size()).isGreaterThanOrEqualTo(2); + assertThat(entryPoints.isArray()).isTrue(); + assertThat(entryPoints.size()).isGreaterThanOrEqualTo(2); + + JsonNode jms = findChainByEndpoint(json, "JMS: order.queue"); + assertThat(jms.path("linkResolution").asText()).isEqualTo("RESOLVED"); + assertThat(jms.path("triggerPoint").path("event").asText()).contains("ORDER_EVENT"); + assertThat(jms.path("matchedTransitions")).isNotEmpty(); + + JsonNode rest = findChainByEndpoint(json, "POST /maven/orders/submit"); + assertThat(rest.path("linkResolution").asText()).isEqualTo("RESOLVED"); + assertThat(rest.path("triggerPoint").path("event").asText()).contains("SUBMIT"); + assertThat(rest.path("matchedTransitions")).isNotEmpty(); + + assertThat(StreamSupport.stream(entryPoints.spliterator(), false) + .anyMatch(ep -> "JMS".equals(ep.path("type").asText()))).isTrue(); + } + + private static JsonNode findChainByEndpoint(JsonNode machineJson, String endpointName) { + return StreamSupport.stream(machineJson.path("metadata").path("callChains").spliterator(), false) + .filter(chain -> endpointName.equals(chain.path("entryPoint").path("name").asText())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("chain not found for " + endpointName)); + } + + private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper) + throws Exception { + Path machineDir; + try (var stream = Files.list(outputDir)) { + machineDir = stream + .filter(Files::isDirectory) + .filter(path -> path.getFileName().toString().endsWith(configBaseName)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No output for " + configBaseName)); + } + return mapper.readTree(machineDir.resolve(machineDir.getFileName() + ".json").toFile()); + } + + private static Path findProjectRoot() { + Path current = Path.of(".").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("settings.gradle"))) { + current = current.getParent(); + } + return current; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathResolutionOptimizationTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathResolutionOptimizationTest.java index 1aa8d43..b7dad75 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathResolutionOptimizationTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathResolutionOptimizationTest.java @@ -80,7 +80,7 @@ class PathResolutionOptimizationTest { assertThat(chains).hasSize(1); assertThat(chains.get(0).getPathBindings()) - .containsEntry("command", "DomainCommand.ORDER_PAY"); + .containsEntry("command", "com.example.DomainCommand.ORDER_PAY"); assertThat(chains.get(0).getMethodChain()).contains("com.example.CentralDispatcher.orderPay"); } 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 829fa24..1e0a3c4 100644 --- a/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json @@ -358,15 +358,15 @@ }, "methodChain" : [ "click.kamil.examples.enterprise.api.OrderController.placeOrder", "click.kamil.examples.enterprise.service.OrderServiceImpl.place" ], "triggerPoint" : { - "event" : "java.lang.String.PLACE_ORDER", + "event" : "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" : "java.lang.String.NEW", + "sourceState" : "String.NEW", "lineNumber" : 16, - "polymorphicEvents" : [ "java.lang.String.PLACE_ORDER" ], + "polymorphicEvents" : [ "String.PLACE_ORDER" ], "external" : false, "constraint" : null, "ambiguous" : false @@ -397,15 +397,15 @@ }, "methodChain" : [ "click.kamil.examples.enterprise.api.OrderController.cancelOrder", "click.kamil.examples.enterprise.service.OrderServiceImpl.cancel" ], "triggerPoint" : { - "event" : "java.lang.String.CANCEL_ORDER", + "event" : "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" : "java.lang.String.PAID", + "sourceState" : "String.PAID", "lineNumber" : 21, - "polymorphicEvents" : [ "java.lang.String.CANCEL_ORDER" ], + "polymorphicEvents" : [ "String.CANCEL_ORDER" ], "external" : false, "constraint" : null, "ambiguous" : false @@ -431,7 +431,7 @@ }, "methodChain" : [ "click.kamil.examples.enterprise.api.SecurityInterceptor.preHandle" ], "triggerPoint" : { - "event" : "java.lang.String.AUDIT_EVENT", + "event" : "String.AUDIT_EVENT", "className" : "click.kamil.examples.enterprise.api.SecurityInterceptor", "methodName" : "preHandle", "sourceFile" : "src/main/java/click/kamil/examples/enterprise/api/SecurityInterceptor.java", @@ -466,15 +466,15 @@ }, "methodChain" : [ "click.kamil.examples.enterprise.api.ReactivePaymentController.pay", "click.kamil.examples.enterprise.service.ReactivePaymentService.processPayment" ], "triggerPoint" : { - "event" : "java.lang.String.PAY_ORDER", + "event" : "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" : "java.lang.String.PENDING_PAYMENT", + "sourceState" : "String.PENDING_PAYMENT", "lineNumber" : 18, - "polymorphicEvents" : [ "java.lang.String.PAY_ORDER" ], + "polymorphicEvents" : [ "String.PAY_ORDER" ], "external" : false, "constraint" : null, "ambiguous" : false @@ -505,13 +505,13 @@ }, "methodChain" : [ "click.kamil.examples.enterprise.messaging.ShippingJmsListener.onShippingReady" ], "triggerPoint" : { - "event" : "java.lang.String.SHIP_ORDER", + "event" : "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" : "java.lang.String.PAID", + "sourceState" : "String.PAID", "lineNumber" : 17, "polymorphicEvents" : null, "external" : false, @@ -544,13 +544,13 @@ }, "methodChain" : [ "click.kamil.examples.enterprise.messaging.ReturnsRabbitListener.onReturn" ], "triggerPoint" : { - "event" : "java.lang.String.RETURN_ORDER", + "event" : "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" : "java.lang.String.DELIVERED", + "sourceState" : "String.DELIVERED", "lineNumber" : 17, "polymorphicEvents" : null, "external" : false, 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 c009f90..abfd749 100644 --- a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json @@ -189,15 +189,15 @@ }, "methodChain" : [ "click.kamil.examples.statemachine.extended.web.OrderController.reactiveOrder", "click.kamil.examples.statemachine.extended.service.ReactiveOrderService.processReactive" ], "triggerPoint" : { - "event" : "java.lang.String.REACTIVE_EVENT", + "event" : "String.REACTIVE_EVENT", "className" : "click.kamil.examples.statemachine.extended.service.ReactiveOrderService", "methodName" : "processReactive", "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/ReactiveOrderService.java", "sourceModule" : null, "stateMachineId" : null, - "sourceState" : "java.lang.String.START", + "sourceState" : "String.START", "lineNumber" : 18, - "polymorphicEvents" : [ "java.lang.String.REACTIVE_EVENT" ], + "polymorphicEvents" : [ "String.REACTIVE_EVENT" ], "external" : false, "constraint" : null, "ambiguous" : false 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 c009f90..abfd749 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 @@ -189,15 +189,15 @@ }, "methodChain" : [ "click.kamil.examples.statemachine.extended.web.OrderController.reactiveOrder", "click.kamil.examples.statemachine.extended.service.ReactiveOrderService.processReactive" ], "triggerPoint" : { - "event" : "java.lang.String.REACTIVE_EVENT", + "event" : "String.REACTIVE_EVENT", "className" : "click.kamil.examples.statemachine.extended.service.ReactiveOrderService", "methodName" : "processReactive", "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/ReactiveOrderService.java", "sourceModule" : null, "stateMachineId" : null, - "sourceState" : "java.lang.String.START", + "sourceState" : "String.START", "lineNumber" : 18, - "polymorphicEvents" : [ "java.lang.String.REACTIVE_EVENT" ], + "polymorphicEvents" : [ "String.REACTIVE_EVENT" ], "external" : false, "constraint" : null, "ambiguous" : false 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 87db0a5..2e20cb4 100644 --- a/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json @@ -73,15 +73,15 @@ }, "methodChain" : [ "click.kamil.examples.statemachine.inheritance.api.OrderControllerImpl.submitOrder", "click.kamil.examples.statemachine.inheritance.service.ProcessingServiceImpl.doProcess" ], "triggerPoint" : { - "event" : "java.lang.String.INHERITED_SUBMIT", + "event" : "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" : "java.lang.String.START", + "sourceState" : "String.START", "lineNumber" : 15, - "polymorphicEvents" : [ "java.lang.String.INHERITED_SUBMIT" ], + "polymorphicEvents" : [ "String.INHERITED_SUBMIT" ], "external" : false, "constraint" : null, "ambiguous" : false 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 661f17c..db2597d 100644 --- a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json +++ b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json @@ -61,6 +61,21 @@ "metadata" : { "triggers" : [ ], "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" ] + } ] + }, { "type" : "JMS", "name" : "JMS: order.queue", "className" : "click.kamil.maven.api.JmsOrderListener", @@ -77,6 +92,45 @@ } ] } ], "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" : "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" : "String.INIT", + "lineNumber" : 40, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "String.INIT", + "targetState" : "String.BUSY", + "event" : "String.SUBMIT" + } ], + "linkResolution" : "RESOLVED" + }, { "entryPoint" : { "type" : "JMS", "name" : "JMS: order.queue", @@ -93,17 +147,17 @@ "annotations" : [ ] } ] }, - "methodChain" : [ "click.kamil.maven.api.JmsOrderListener.onMessage", "click.kamil.maven.core.MavenOrderStateMachine.OrderService.onMessage" ], + "methodChain" : [ "click.kamil.maven.core.MavenOrderStateMachine.OrderService.onMessage" ], "triggerPoint" : { - "event" : "java.lang.String.ORDER_EVENT", + "event" : "String.ORDER_EVENT", "className" : "click.kamil.maven.core.MavenOrderStateMachine.OrderService", "methodName" : "onMessage", "sourceFile" : "core-module/src/main/java/click/kamil/maven/core/MavenOrderStateMachine.java", "sourceModule" : null, "stateMachineId" : null, - "sourceState" : "java.lang.String.BUSY", + "sourceState" : "String.BUSY", "lineNumber" : 52, - "polymorphicEvents" : [ "java.lang.String.ORDER_EVENT" ], + "polymorphicEvents" : null, "external" : false, "constraint" : null, "ambiguous" : false 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 af4625b..d7a5c7e 100644 --- a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json @@ -108,13 +108,13 @@ }, "methodChain" : [ "click.kamil.multi.core.OrderController.submitOrder" ], "triggerPoint" : { - "event" : "java.lang.String.SUBMIT", + "event" : "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" : "java.lang.String.NEW", + "sourceState" : "String.NEW", "lineNumber" : 18, "polymorphicEvents" : null, "external" : false, diff --git a/state_machine_exporter/src/test/resources/golden/PaymentStateMachineConfig/PaymentStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/PaymentStateMachineConfig/PaymentStateMachineConfig.json index d847f66..47bdc7d 100644 --- a/state_machine_exporter/src/test/resources/golden/PaymentStateMachineConfig/PaymentStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/PaymentStateMachineConfig/PaymentStateMachineConfig.json @@ -367,15 +367,15 @@ }, "methodChain" : [ "click.kamil.examples.statemachine.extended.web.SetterInjectionController.testSetter", "click.kamil.examples.statemachine.extended.service.NamedQuirkService.doQuirk" ], "triggerPoint" : { - "event" : "java.lang.String.NAMED_EVENT", + "event" : "String.NAMED_EVENT", "className" : "click.kamil.examples.statemachine.extended.service.NamedQuirkService", "methodName" : "doQuirk", "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/NamedQuirkService.java", "sourceModule" : null, "stateMachineId" : "paymentStateMachine", - "sourceState" : "java.lang.String.NEW", + "sourceState" : "String.NEW", "lineNumber" : 17, - "polymorphicEvents" : [ "java.lang.String.NAMED_EVENT" ], + "polymorphicEvents" : [ "String.NAMED_EVENT" ], "external" : false, "constraint" : null, "ambiguous" : false @@ -402,15 +402,15 @@ }, "methodChain" : [ "click.kamil.examples.statemachine.extended.web.QuirkController.testPrimary", "click.kamil.examples.statemachine.extended.service.PrimaryQuirkService.doQuirk" ], "triggerPoint" : { - "event" : "java.lang.String.PRIMARY_EVENT", + "event" : "String.PRIMARY_EVENT", "className" : "click.kamil.examples.statemachine.extended.service.PrimaryQuirkService", "methodName" : "doQuirk", "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/PrimaryQuirkService.java", "sourceModule" : null, "stateMachineId" : "paymentStateMachine", - "sourceState" : "java.lang.String.NEW", + "sourceState" : "String.NEW", "lineNumber" : 19, - "polymorphicEvents" : [ "java.lang.String.PRIMARY_EVENT" ], + "polymorphicEvents" : [ "String.PRIMARY_EVENT" ], "external" : false, "constraint" : null, "ambiguous" : false @@ -437,15 +437,15 @@ }, "methodChain" : [ "click.kamil.examples.statemachine.extended.web.QuirkController.testNamed", "click.kamil.examples.statemachine.extended.service.NamedQuirkService.doQuirk" ], "triggerPoint" : { - "event" : "java.lang.String.NAMED_EVENT", + "event" : "String.NAMED_EVENT", "className" : "click.kamil.examples.statemachine.extended.service.NamedQuirkService", "methodName" : "doQuirk", "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/NamedQuirkService.java", "sourceModule" : null, "stateMachineId" : "paymentStateMachine", - "sourceState" : "java.lang.String.NEW", + "sourceState" : "String.NEW", "lineNumber" : 17, - "polymorphicEvents" : [ "java.lang.String.NAMED_EVENT" ], + "polymorphicEvents" : [ "String.NAMED_EVENT" ], "external" : false, "constraint" : null, "ambiguous" : false @@ -472,15 +472,15 @@ }, "methodChain" : [ "click.kamil.examples.statemachine.extended.web.QuirkController.testQualifier", "click.kamil.examples.statemachine.extended.service.QualifierQuirkService.doQuirk" ], "triggerPoint" : { - "event" : "java.lang.String.QUALIFIER_EVENT", + "event" : "String.QUALIFIER_EVENT", "className" : "click.kamil.examples.statemachine.extended.service.QualifierQuirkService", "methodName" : "doQuirk", "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/QualifierQuirkService.java", "sourceModule" : null, "stateMachineId" : "paymentStateMachine", - "sourceState" : "java.lang.String.NEW", + "sourceState" : "String.NEW", "lineNumber" : 17, - "polymorphicEvents" : [ "java.lang.String.QUALIFIER_EVENT" ], + "polymorphicEvents" : [ "String.QUALIFIER_EVENT" ], "external" : false, "constraint" : null, "ambiguous" : false @@ -507,15 +507,15 @@ }, "methodChain" : [ "click.kamil.examples.statemachine.extended.web.QuirkController.testFallback", "click.kamil.examples.statemachine.extended.service.PrimaryQuirkService.doQuirk" ], "triggerPoint" : { - "event" : "java.lang.String.PRIMARY_EVENT", + "event" : "String.PRIMARY_EVENT", "className" : "click.kamil.examples.statemachine.extended.service.PrimaryQuirkService", "methodName" : "doQuirk", "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/PrimaryQuirkService.java", "sourceModule" : null, "stateMachineId" : "paymentStateMachine", - "sourceState" : "java.lang.String.NEW", + "sourceState" : "String.NEW", "lineNumber" : 19, - "polymorphicEvents" : [ "java.lang.String.PRIMARY_EVENT" ], + "polymorphicEvents" : [ "String.PRIMARY_EVENT" ], "external" : false, "constraint" : null, "ambiguous" : false @@ -542,15 +542,15 @@ }, "methodChain" : [ "click.kamil.examples.statemachine.extended.web.ConcreteReturnTypeController.testConcrete", "click.kamil.examples.statemachine.extended.service.QualifierQuirkService.doQuirk" ], "triggerPoint" : { - "event" : "java.lang.String.QUALIFIER_EVENT", + "event" : "String.QUALIFIER_EVENT", "className" : "click.kamil.examples.statemachine.extended.service.QualifierQuirkService", "methodName" : "doQuirk", "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/QualifierQuirkService.java", "sourceModule" : null, "stateMachineId" : "paymentStateMachine", - "sourceState" : "java.lang.String.NEW", + "sourceState" : "String.NEW", "lineNumber" : 17, - "polymorphicEvents" : [ "java.lang.String.QUALIFIER_EVENT" ], + "polymorphicEvents" : [ "String.QUALIFIER_EVENT" ], "external" : false, "constraint" : null, "ambiguous" : false @@ -577,15 +577,15 @@ }, "methodChain" : [ "click.kamil.examples.statemachine.extended.web.FieldInjectionController.testField", "click.kamil.examples.statemachine.extended.service.NamedQuirkService.doQuirk" ], "triggerPoint" : { - "event" : "java.lang.String.NAMED_EVENT", + "event" : "String.NAMED_EVENT", "className" : "click.kamil.examples.statemachine.extended.service.NamedQuirkService", "methodName" : "doQuirk", "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/NamedQuirkService.java", "sourceModule" : null, "stateMachineId" : "paymentStateMachine", - "sourceState" : "java.lang.String.NEW", + "sourceState" : "String.NEW", "lineNumber" : 17, - "polymorphicEvents" : [ "java.lang.String.NAMED_EVENT" ], + "polymorphicEvents" : [ "String.NAMED_EVENT" ], "external" : false, "constraint" : null, "ambiguous" : false @@ -616,15 +616,15 @@ }, "methodChain" : [ "click.kamil.examples.statemachine.extended.web.PaymentController.processBaseEndpoint", "click.kamil.examples.statemachine.extended.service.PaymentServiceImpl.processPayment" ], "triggerPoint" : { - "event" : "java.lang.String.AUTHORIZE", + "event" : "String.AUTHORIZE", "className" : "click.kamil.examples.statemachine.extended.service.PaymentServiceImpl", "methodName" : "processPayment", "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/PaymentServiceImpl.java", "sourceModule" : null, "stateMachineId" : "paymentStateMachine", - "sourceState" : "java.lang.String.NEW", + "sourceState" : "String.NEW", "lineNumber" : 22, - "polymorphicEvents" : [ "java.lang.String.AUTHORIZE" ], + "polymorphicEvents" : [ "String.AUTHORIZE" ], "external" : false, "constraint" : null, "ambiguous" : false @@ -655,15 +655,15 @@ }, "methodChain" : [ "click.kamil.examples.statemachine.extended.web.PaymentController.capturePaymentEndpoint", "click.kamil.examples.statemachine.extended.service.PaymentServiceImpl.capturePayment" ], "triggerPoint" : { - "event" : "java.lang.String.CAPTURE", + "event" : "String.CAPTURE", "className" : "click.kamil.examples.statemachine.extended.service.PaymentServiceImpl", "methodName" : "capturePayment", "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/PaymentServiceImpl.java", "sourceModule" : null, "stateMachineId" : "paymentStateMachine", - "sourceState" : "java.lang.String.AUTHORIZED", + "sourceState" : "String.AUTHORIZED", "lineNumber" : 35, - "polymorphicEvents" : [ "java.lang.String.CAPTURE" ], + "polymorphicEvents" : [ "String.CAPTURE" ], "external" : false, "constraint" : null, "ambiguous" : false