From e6effa3dcdb3ca04f8eb0fe3818328eb31e35484 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Sun, 12 Jul 2026 16:54:14 +0200 Subject: [PATCH] Harden call-graph enum resolution and JSON re-export polymorphic repair. Replace eager enum widen with symbolic placeholders and deferred narrowing, add switch-based predicate evaluation and entry-point synthesis for JSON round-trips. Co-authored-by: Cursor --- .../analysis/enricher/CallChainEnricher.java | 39 ++ .../TriggerCanonicalizationEnricher.java | 9 +- .../EnumMemberPredicateEvaluator.java | 190 ++++++- .../resolver/MachineEnumCanonicalizer.java | 41 +- .../service/AbstractCallGraphEngine.java | 535 ++++++++++++++++-- .../analysis/service/ConstantExtractor.java | 76 ++- .../analysis/service/EnrichmentService.java | 20 +- .../AnalysisCanonicalFormValidator.java | 3 - .../service/ExportService.java | 6 +- ...mberPredicatePolymorphicInferenceTest.java | 55 ++ .../CentralDispatcherResolutionTest.java | 48 ++ .../JsonRoundTripCanonicalizationTest.java | 77 +++ .../AnalysisCanonicalFormValidatorTest.java | 36 ++ 13 files changed, 1066 insertions(+), 69 deletions(-) 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 f37e1e8..c7fa1cf 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 @@ -9,7 +9,10 @@ import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntellige import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import lombok.extern.slf4j.Slf4j; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; @Slf4j public class CallChainEnricher implements AnalysisEnricher { @@ -47,6 +50,9 @@ public class CallChainEnricher implements AnalysisEnricher { return null; } List entryPoints = result.getMetadata().getEntryPoints(); + if (entryPoints == null || entryPoints.isEmpty()) { + entryPoints = synthesizeEntryPoints(result.getMetadata().getCallChains()); + } if (entryPoints == null || entryPoints.isEmpty()) { return null; } @@ -65,4 +71,37 @@ public class CallChainEnricher implements AnalysisEnricher { List chains = intelligence.findCallChains(entryPoints, scopedTriggers); return MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context); } + + private static List synthesizeEntryPoints(List callChains) { + if (callChains == null || callChains.isEmpty()) { + return List.of(); + } + Map byMethod = new LinkedHashMap<>(); + for (CallChain chain : callChains) { + EntryPoint entryPoint = chain.getEntryPoint(); + if (hasClassAndMethod(entryPoint)) { + byMethod.putIfAbsent(entryPoint.getClassName() + "#" + entryPoint.getMethodName(), entryPoint); + continue; + } + TriggerPoint triggerPoint = chain.getTriggerPoint(); + if (triggerPoint != null + && triggerPoint.getClassName() != null + && triggerPoint.getMethodName() != null) { + EntryPoint fallback = EntryPoint.builder() + .type(EntryPoint.Type.CUSTOM) + .className(triggerPoint.getClassName()) + .methodName(triggerPoint.getMethodName()) + .sourceFile(triggerPoint.getSourceFile()) + .build(); + byMethod.putIfAbsent(fallback.getClassName() + "#" + fallback.getMethodName(), fallback); + } + } + return new ArrayList<>(byMethod.values()); + } + + private static boolean hasClassAndMethod(EntryPoint entryPoint) { + return entryPoint != null + && entryPoint.getClassName() != null + && entryPoint.getMethodName() != null; + } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TriggerCanonicalizationEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TriggerCanonicalizationEnricher.java index 9eea440..8c5951a 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TriggerCanonicalizationEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TriggerCanonicalizationEnricher.java @@ -45,10 +45,11 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher { if (chain.getTriggerPoint() == null) { return chain; } - return chain.toBuilder() - .triggerPoint(MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint( - chain.getTriggerPoint(), machineTypes, context)) - .build(); + TriggerPoint canonical = MachineEnumCanonicalizer.canonicalizeTriggerPoint( + chain.getTriggerPoint(), machineTypes); + TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( + canonical, machineTypes, context, result.getTransitions(), true); + return chain.toBuilder().triggerPoint(enriched).build(); }) .collect(Collectors.toList()); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicateEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicateEvaluator.java index a45c837..e2ffe98 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicateEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicateEvaluator.java @@ -4,21 +4,30 @@ import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import org.eclipse.jdt.core.dom.ASTVisitor; import org.eclipse.jdt.core.dom.BooleanLiteral; import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.ConditionalExpression; import org.eclipse.jdt.core.dom.EnumConstantDeclaration; import org.eclipse.jdt.core.dom.EnumDeclaration; import org.eclipse.jdt.core.dom.AbstractTypeDeclaration; +import org.eclipse.jdt.core.dom.Block; import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.ExpressionStatement; import org.eclipse.jdt.core.dom.FieldAccess; import org.eclipse.jdt.core.dom.FieldDeclaration; import org.eclipse.jdt.core.dom.MethodDeclaration; import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.ParenthesizedExpression; +import org.eclipse.jdt.core.dom.QualifiedName; import org.eclipse.jdt.core.dom.ReturnStatement; import org.eclipse.jdt.core.dom.SimpleName; import org.eclipse.jdt.core.dom.SimpleType; import org.eclipse.jdt.core.dom.SingleVariableDeclaration; +import org.eclipse.jdt.core.dom.SwitchCase; +import org.eclipse.jdt.core.dom.SwitchExpression; +import org.eclipse.jdt.core.dom.SwitchStatement; import org.eclipse.jdt.core.dom.ThisExpression; import org.eclipse.jdt.core.dom.TypeDeclaration; import org.eclipse.jdt.core.dom.VariableDeclarationFragment; +import org.eclipse.jdt.core.dom.YieldStatement; import java.util.ArrayList; import java.util.HashMap; @@ -97,7 +106,8 @@ public final class EnumMemberPredicateEvaluator { } Map boolFields = resolveConstantBooleanFields(enumDecl, constantName); for (PredicateCall predicate : predicates) { - Boolean methodResult = evaluateBooleanMethod(enumDecl, predicate.methodName(), boolFields, context); + Boolean methodResult = evaluateBooleanMethod( + enumDecl, predicate.methodName(), constantName, boolFields, context); if (methodResult == null) { return false; } @@ -112,6 +122,7 @@ public final class EnumMemberPredicateEvaluator { static Boolean evaluateBooleanMethod( EnumDeclaration enumDecl, String methodName, + String currentConstantName, Map boolFields, CodebaseContext context) { MethodDeclaration method = findParameterlessMethod(enumDecl, methodName); @@ -121,12 +132,18 @@ public final class EnumMemberPredicateEvaluator { if (method == null || method.getBody() == null) { return null; } + Boolean switchResult = evaluateSwitchDrivenMethod( + method, currentConstantName, boolFields, enumDecl, context); + if (switchResult != null) { + return switchResult; + } Boolean[] result = new Boolean[1]; method.getBody().accept(new ASTVisitor() { @Override public boolean visit(ReturnStatement node) { if (result[0] == null) { - result[0] = evaluateBooleanExpression(node.getExpression(), boolFields, enumDecl, context); + result[0] = evaluateBooleanExpression( + node.getExpression(), currentConstantName, boolFields, enumDecl, context); } return super.visit(node); } @@ -134,6 +151,35 @@ public final class EnumMemberPredicateEvaluator { return result[0]; } + private static Boolean evaluateSwitchDrivenMethod( + MethodDeclaration method, + String currentConstantName, + Map boolFields, + EnumDeclaration enumDecl, + CodebaseContext context) { + Boolean[] result = new Boolean[1]; + method.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(SwitchStatement node) { + if (result[0] == null && isEnumSelfSelector(node.getExpression())) { + result[0] = evaluateSwitchLike( + node.statements(), currentConstantName, boolFields, enumDecl, context); + } + return result[0] == null; + } + + @Override + public boolean visit(SwitchExpression node) { + if (result[0] == null && isEnumSelfSelector(node.getExpression())) { + result[0] = evaluateSwitchLike( + node.statements(), currentConstantName, boolFields, enumDecl, context); + } + return result[0] == null; + } + }); + return result[0]; + } + static Map resolveConstantBooleanFields(EnumDeclaration enumDecl, String constantName) { Map fields = new HashMap<>(); EnumConstantDeclaration constant = findEnumConstant(enumDecl, constantName); @@ -189,6 +235,7 @@ public final class EnumMemberPredicateEvaluator { private static Boolean evaluateBooleanExpression( Expression expr, + String currentConstantName, Map boolFields, EnumDeclaration enumDecl, CodebaseContext context) { @@ -203,11 +250,148 @@ public final class EnumMemberPredicateEvaluator { return boolFields.get(fa.getName().getIdentifier()); } } + if (expr instanceof ParenthesizedExpression pe) { + return evaluateBooleanExpression( + pe.getExpression(), currentConstantName, boolFields, enumDecl, context); + } + if (expr instanceof ConditionalExpression ce) { + Boolean thenVal = evaluateBooleanExpression( + ce.getThenExpression(), currentConstantName, boolFields, enumDecl, context); + Boolean elseVal = evaluateBooleanExpression( + ce.getElseExpression(), currentConstantName, boolFields, enumDecl, context); + if (thenVal != null && elseVal != null && thenVal.equals(elseVal)) { + return thenVal; + } + return null; + } + if (expr instanceof SwitchExpression se && isEnumSelfSelector(se.getExpression())) { + return evaluateSwitchLike(se.statements(), currentConstantName, boolFields, enumDecl, context); + } if (expr instanceof MethodInvocation mi && mi.arguments().isEmpty() && enumDecl != null && context != null) { - return evaluateBooleanMethod(enumDecl, mi.getName().getIdentifier(), boolFields, context); + return evaluateBooleanMethod( + enumDecl, mi.getName().getIdentifier(), currentConstantName, boolFields, context); + } + return null; + } + + private static Boolean evaluateSwitchLike( + List statements, + String currentConstantName, + Map boolFields, + EnumDeclaration enumDecl, + CodebaseContext context) { + List caseConstants = new ArrayList<>(); + boolean defaultCase = false; + boolean sawArmBody = false; + boolean pendingCase = false; + for (Object statementObj : statements) { + if (statementObj instanceof SwitchCase switchCase) { + if (sawArmBody) { + caseConstants.clear(); + defaultCase = false; + sawArmBody = false; + } + pendingCase = true; + if (switchCase.isDefault()) { + defaultCase = true; + } else { + for (Object exprObj : switchCase.expressions()) { + if (exprObj instanceof Expression caseExpr) { + String caseConstant = extractCaseConstantName(caseExpr); + if (caseConstant != null) { + caseConstants.add(caseConstant); + } + } + } + } + continue; + } + if (!pendingCase) { + continue; + } + sawArmBody = true; + if (!defaultCase && !caseConstants.contains(currentConstantName)) { + continue; + } + Boolean armValue = evaluateSwitchArmNode( + statementObj, currentConstantName, boolFields, enumDecl, context); + if (armValue != null) { + return armValue; + } + } + return null; + } + + private static Boolean evaluateSwitchArmNode( + Object node, + String currentConstantName, + Map boolFields, + EnumDeclaration enumDecl, + CodebaseContext context) { + if (node instanceof ReturnStatement rs) { + return evaluateBooleanExpression( + rs.getExpression(), currentConstantName, boolFields, enumDecl, context); + } + if (node instanceof YieldStatement ys) { + return evaluateBooleanExpression( + ys.getExpression(), currentConstantName, boolFields, enumDecl, context); + } + if (node instanceof ExpressionStatement es) { + return evaluateBooleanExpression( + es.getExpression(), currentConstantName, boolFields, enumDecl, context); + } + if (node instanceof Block block) { + return evaluateLinearBooleanStatements( + block.statements(), currentConstantName, boolFields, enumDecl, context); + } + if (node instanceof Expression expression) { + return evaluateBooleanExpression( + expression, currentConstantName, boolFields, enumDecl, context); + } + return null; + } + + private static Boolean evaluateLinearBooleanStatements( + List statements, + String currentConstantName, + Map boolFields, + EnumDeclaration enumDecl, + CodebaseContext context) { + for (Object statementObj : statements) { + Boolean value = evaluateSwitchArmNode( + statementObj, currentConstantName, boolFields, enumDecl, context); + if (value != null) { + return value; + } + } + return null; + } + + private static boolean isEnumSelfSelector(Expression expr) { + if (expr instanceof ThisExpression) { + return true; + } + if (expr instanceof ParenthesizedExpression pe) { + return isEnumSelfSelector(pe.getExpression()); + } + return false; + } + + private static String extractCaseConstantName(Expression expr) { + if (expr instanceof SimpleName simpleName) { + return simpleName.getIdentifier(); + } + if (expr instanceof QualifiedName qualifiedName) { + return qualifiedName.getName().getIdentifier(); + } + if (expr instanceof FieldAccess fieldAccess) { + return fieldAccess.getName().getIdentifier(); + } + if (expr instanceof ParenthesizedExpression pe) { + return extractCaseConstantName(pe.getExpression()); } return null; } 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 d4ba836..56fcf1c 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 @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -146,10 +147,32 @@ public final class MachineEnumCanonicalizer { } List expanded = expandSymbolicPolymorphicEvents( canonical.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context); - if (expanded == canonical.getPolymorphicEvents()) { + List narrowed = narrowExpandedPolymorphicEvents( + expanded, + canonical.getConstraint(), + machineTypes.eventTypeFqn(), + null, + context); + if (Objects.equals(narrowed, canonical.getPolymorphicEvents())) { return canonical; } - return canonical.toBuilder().polymorphicEvents(expanded).build(); + return canonical.toBuilder().polymorphicEvents(narrowed).build(); + } + + /** + * Expands symbolic placeholders and applies predicate/transition narrowing when possible. + */ + public static List narrowExpandedPolymorphicEvents( + List polymorphicEvents, + String constraint, + String eventTypeFqn, + List machineTransitions, + CodebaseContext context) { + if (polymorphicEvents == null || polymorphicEvents.isEmpty()) { + return polymorphicEvents; + } + return narrowPolymorphicCandidates( + polymorphicEvents, constraint, eventTypeFqn, machineTransitions, context); } /** @@ -184,6 +207,20 @@ public final class MachineEnumCanonicalizer { if (expanded == null || machineTypes == null || machineTypes.eventTypeFqn() == null) { return expanded; } + List postExpand = expanded.getPolymorphicEvents(); + if (postExpand != null && postExpand.stream().anyMatch(pe -> pe != null && pe.startsWith(" narrowed = narrowPolymorphicCandidates( expanded.getPolymorphicEvents(), diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 597db6d..dc72f18 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -677,33 +677,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { sourceMethod = "inline-ternary"; declaredType = polymorphicEvents.isEmpty() ? null : polymorphicEvents.get(0); } else if (exprNode instanceof SwitchExpression swExpr) { - List polyEventsRef = polymorphicEvents; - swExpr.accept(new ASTVisitor() { - @Override - public boolean visit(YieldStatement ys) { - List tracedBranch = variableTracer.traceVariableAll(ys.getExpression()); - for (Expression tb : tracedBranch) { - if (tb instanceof ClassInstanceCreation cic) { - polyEventsRef.add(click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType())); - } else { - constantExtractor.extractConstantsFromExpression(tb, polyEventsRef); - } - } - return super.visit(ys); - } - @Override - public boolean visit(ExpressionStatement es) { - List tracedBranch = variableTracer.traceVariableAll(es.getExpression()); - for (Expression tb : tracedBranch) { - if (tb instanceof ClassInstanceCreation cic) { - polyEventsRef.add(click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType())); - } else { - constantExtractor.extractConstantsFromExpression(tb, polyEventsRef); - } - } - return super.visit(es); - } - }); + constantExtractor.extractConstantsFromExpression(swExpr, polymorphicEvents); sourceMethod = "inline-switch"; declaredType = polymorphicEvents.isEmpty() ? null : polymorphicEvents.get(0); } else if (exprNode instanceof SimpleName sn) { @@ -888,12 +862,36 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (declaredType != null) { List enumValues = context.getEnumValues(declaredType); if (enumValues != null && !enumValues.isEmpty()) { + if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context) + && polymorphicEvents.isEmpty() + && exprNode instanceof MethodInvocation miArg + && entryMethod != null + && entryMethod.contains(".")) { + String entryClass = entryMethod.substring(0, entryMethod.lastIndexOf('.')); + List methodReturns = constantExtractor.resolveMethodReturnConstant( + entryClass, + miArg.getName().getIdentifier(), + 0, + new java.util.HashSet<>(), + null); + for (String methodReturn : methodReturns) { + if (methodReturn != null && !polymorphicEvents.contains(methodReturn)) { + polymorphicEvents.add(methodReturn); + } + } + } + if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context) + && polymorphicEvents.isEmpty() + && exprNode instanceof Expression expressionArg) { + addConstantsFromTracedExpression(expressionArg, polymorphicEvents, path, callGraph, entryMethod); + } if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context) && !tp.isExternal() && !isRuntimeEnumParameter(exprNode instanceof Expression expression ? expression : null) && !(keyedMapLookupOnInitializer[0] && pathBoundMapKeyOnInitializer[0])) { - for (String ev : enumValues) { - polymorphicEvents.add(constantExtractor.parseEnumSetElement(ev)); + String symbolic = ""; + if (!polymorphicEvents.contains(symbolic)) { + polymorphicEvents.add(symbolic); } isAmbiguous = true; } @@ -941,6 +939,48 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (!hasValidConstant && exprNode instanceof Expression expressionFallback) { addConstantsFromTracedExpression(expressionFallback, polymorphicEvents, path, callGraph, entryMethod); + List directConstants = new ArrayList<>(); + constantExtractor.extractConstantsFromExpression(expressionFallback, directConstants); + for (String directConstant : directConstants) { + if (directConstant != null && !directConstant.startsWith(" switchConstants = resolveLocalSwitchMethodConstants(enclosingType, fallbackMi); + for (String switchConstant : switchConstants) { + if (switchConstant != null && !switchConstant.startsWith(" methodReturns = constantExtractor.resolveMethodReturnConstant( + context.getFqn(enclosingType), + fallbackMi.getName().getIdentifier(), + 0, + new HashSet<>(), + fallbackCu); + for (String methodReturn : methodReturns) { + if (methodReturn != null && !methodReturn.startsWith(" pe != null && pe.startsWith(" expandedEnumValues = expandDeclaredEnumValues(declaredType); + if (!expandedEnumValues.isEmpty()) { + polymorphicEvents = new ArrayList<>(expandedEnumValues); + } + } + } + } } List newPolyEvents = new ArrayList<>(); @@ -1003,7 +1043,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { unpacked.add(clean); } } - } else if (ev.startsWith("")) { if (!unpacked.contains(ev)) { unpacked.add(ev); } @@ -1019,6 +1059,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { polymorphicEvents = polymorphicEvents.stream().distinct().collect(java.util.stream.Collectors.toList()); + polymorphicEvents.removeIf(pe -> pe != null && (pe.equals("*>") || pe.equals(".*>"))); + polymorphicEvents = narrowPolymorphicEventsWithPathBindings(polymorphicEvents, path, callGraph, tp); Expression runtimeExpr = exprNode instanceof Expression expression ? expression : null; @@ -1165,12 +1207,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return originalExpr.toString(); } - // Preserve field-wired helper calls (routingHelper.payAction()) for per-endpoint literal forwarding. + // Preserve routing helper calls for per-endpoint literal forwarding. if (originalExpr instanceof MethodInvocation helperMi - && helperMi.getExpression() instanceof SimpleName helperReceiver - && !helperReceiver.getIdentifier().isEmpty() - && Character.isLowerCase(helperReceiver.getIdentifier().charAt(0)) && helperMi.arguments().isEmpty() + && isLikelyRoutingHelperReceiver(helperMi.getExpression()) && resolvedValue != null && resolvedValue.matches("^[A-Z_][A-Z0-9_]*$")) { return originalExpr.toString(); @@ -1200,6 +1240,25 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return resolvedValue; } + private boolean isLikelyRoutingHelperReceiver(Expression receiver) { + if (receiver instanceof SimpleName simpleName) { + String identifier = simpleName.getIdentifier(); + return !identifier.isEmpty() + && (Character.isLowerCase(identifier.charAt(0)) + || Character.isUpperCase(identifier.charAt(0))); + } + if (receiver instanceof QualifiedName qualifiedName) { + String identifier = qualifiedName.getName().getIdentifier(); + return !identifier.isEmpty() && Character.isUpperCase(identifier.charAt(0)); + } + if (receiver instanceof TypeLiteral typeLiteral) { + String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils + .extractSimpleTypeName(typeLiteral.getType()); + return typeName != null && !typeName.isEmpty() && Character.isUpperCase(typeName.charAt(0)); + } + return false; + } + protected String getReceiverString(Expression receiver) { if (receiver == null) { return null; @@ -2731,6 +2790,63 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return false; } + private List resolveLocalSwitchMethodConstants(TypeDeclaration ownerType, MethodInvocation methodInvocation) { + if (ownerType == null || methodInvocation == null) { + return List.of(); + } + MethodDeclaration methodDeclaration = context.findMethodDeclaration( + ownerType, methodInvocation.getName().getIdentifier(), true); + if (methodDeclaration == null || methodDeclaration.getBody() == null) { + return List.of(); + } + List constants = new ArrayList<>(); + for (Object statementObj : methodDeclaration.getBody().statements()) { + if (statementObj instanceof SwitchStatement switchStatement) { + constantExtractor.extractConstantsFromSwitchStatement(switchStatement, constants); + } else if (statementObj instanceof ReturnStatement returnStatement + && returnStatement.getExpression() instanceof SwitchExpression switchExpression) { + constantExtractor.extractConstantsFromExpression(switchExpression, constants); + } + } + return constants; + } + + private List expandDeclaredEnumValues(String declaredType) { + if (declaredType == null) { + return List.of(); + } + List enumValues = context.getEnumValues(declaredType); + if ((enumValues == null || enumValues.isEmpty()) && declaredType.contains(".")) { + enumValues = context.getEnumValues(declaredType.substring(declaredType.lastIndexOf('.') + 1)); + } + if ((enumValues == null || enumValues.isEmpty())) { + String simpleDeclared = declaredType.contains(".") + ? declaredType.substring(declaredType.lastIndexOf('.') + 1) + : declaredType; + for (Map.Entry> entry : context.getEnumValuesMap().entrySet()) { + String enumType = entry.getKey(); + String simpleType = enumType.contains(".") + ? enumType.substring(enumType.lastIndexOf('.') + 1) + : enumType; + if (simpleDeclared.equals(simpleType)) { + enumValues = entry.getValue(); + break; + } + } + } + if (enumValues == null || enumValues.isEmpty()) { + return List.of(); + } + List expanded = new ArrayList<>(); + for (String enumValue : enumValues) { + String parsed = constantExtractor.parseEnumSetElement(enumValue); + if (parsed != null && !expanded.contains(parsed)) { + expanded.add(parsed); + } + } + return expanded; + } + private boolean isRuntimeEnumParameter(Expression exprNode) { if (!(exprNode instanceof MethodInvocation mi)) { return false; @@ -2876,9 +2992,38 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } Map bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); if (bindings.isEmpty()) { + if (hasMultiClassPolymorphicPath(path) + && polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) { + List callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path); + if (!callerArgumentResolved.isEmpty()) { + return callerArgumentResolved; + } + } return polymorphicEvents; } List narrowed = new ArrayList<>(polymorphicEvents); + if (narrowed.stream().noneMatch(this::looksLikeEnumConstant)) { + List bindingResolved = resolvePolymorphicEventsFromBindings(bindings, path); + boolean bindingHasConcrete = bindingResolved.stream().anyMatch(this::looksLikeEnumConstant); + if (bindingHasConcrete) { + narrowed = new ArrayList<>(bindingResolved); + } else { + for (String candidate : bindingResolved) { + if (candidate != null && !narrowed.contains(candidate)) { + narrowed.add(candidate); + } + } + } + } + boolean originallySymbolicOnly = polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant); + if (originallySymbolicOnly && narrowed.stream().filter(this::looksLikeEnumConstant).count() <= 1) { + List callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path); + for (String candidate : callerArgumentResolved) { + if (candidate != null && !narrowed.contains(candidate)) { + narrowed.add(candidate); + } + } + } boolean hasConcrete = narrowed.stream().anyMatch(this::looksLikeEnumConstant); if (hasConcrete) { narrowed.removeIf(pe -> pe != null && pe.startsWith(" resolvePolymorphicEventsFromBindings( + Map bindings, + List path) { + if (bindings == null || bindings.isEmpty() || path == null || path.isEmpty()) { + return List.of(); + } + String callerMethod = path.size() >= 2 ? path.get(path.size() - 2) : path.get(0); + String callerClass = callerMethod.contains(".") + ? callerMethod.substring(0, callerMethod.lastIndexOf('.')) + : callerMethod; + TypeDeclaration callerType = context.getTypeDeclaration(callerClass); + List resolved = new ArrayList<>(); + for (String boundValue : bindings.values()) { + if (boundValue == null || boundValue.isBlank()) { + continue; + } + if (boundValue.startsWith("ENUM_SET:") + || boundValue.startsWith("")) { + addResolvedConstantToList(boundValue, resolved); + continue; + } + ASTNode parsed = parseExpressionString(boundValue); + if (!(parsed instanceof Expression boundExpr)) { + addResolvedConstantToList(boundValue, resolved); + continue; + } + String directResolved = constantResolver.resolve(boundExpr, context); + if (directResolved != null) { + addResolvedConstantToList(directResolved, resolved); + } + List extracted = new ArrayList<>(); + if (boundExpr instanceof MethodInvocation methodInvocation + && callerType != null + && (methodInvocation.getExpression() == null + || methodInvocation.getExpression() instanceof ThisExpression)) { + extracted.addAll(resolveLocalSwitchMethodConstants(callerType, methodInvocation)); + } + if (extracted.isEmpty()) { + constantExtractor.extractConstantsFromExpression(boundExpr, extracted); + } + for (String candidate : extracted) { + addResolvedConstantToList(candidate, resolved); + } + } + return resolved.stream().distinct().collect(java.util.stream.Collectors.toList()); + } + + private List resolvePolymorphicEventsFromCallerArgument(List path) { + if (path == null || path.size() < 2) { + return List.of(); + } + String callerMethod = path.get(path.size() - 2); + String targetMethod = path.get(path.size() - 1); + String callerClass = callerMethod.substring(0, callerMethod.lastIndexOf('.')); + String callerMethodName = callerMethod.substring(callerMethod.lastIndexOf('.') + 1); + String targetMethodName = targetMethod.substring(targetMethod.lastIndexOf('.') + 1); + TypeDeclaration callerType = context.getTypeDeclaration(callerClass); + if (callerType == null) { + return List.of(); + } + MethodDeclaration callerDeclaration = context.findMethodDeclaration(callerType, callerMethodName, true); + if (callerDeclaration == null || callerDeclaration.getBody() == null) { + return List.of(); + } + List resolved = new ArrayList<>(); + final boolean[] expansionHint = {false}; + callerDeclaration.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (!targetMethodName.equals(node.getName().getIdentifier()) || node.arguments().isEmpty()) { + return super.visit(node); + } + Expression argument = (Expression) node.arguments().get(0); + List candidateExpressions = new ArrayList<>(); + if (argument instanceof SimpleName && variableTracer != null) { + candidateExpressions.addAll(variableTracer.traceVariableAll(argument)); + } + if (candidateExpressions.isEmpty()) { + candidateExpressions.add(argument); + } + for (Expression candidateExpr : candidateExpressions) { + if (candidateExpr == null) { + continue; + } + if (candidateExpr.toString().contains(".toUpperCase()")) { + expansionHint[0] = true; + } + if (candidateExpr instanceof MethodInvocation localMethod + && (localMethod.getExpression() == null + || localMethod.getExpression() instanceof ThisExpression)) { + String resolvedPathMethod = findPathMethodByName(path, localMethod.getName().getIdentifier()); + if (resolvedPathMethod == null) { + String fallbackOwner = findPrecedingConcretePathClass(path, callerClass); + if (fallbackOwner != null) { + TypeDeclaration fallbackType = context.getTypeDeclaration(fallbackOwner); + MethodDeclaration fallbackMethod = fallbackType != null + ? context.findMethodDeclaration( + fallbackType, localMethod.getName().getIdentifier(), true) + : null; + if (fallbackMethod != null) { + resolvedPathMethod = fallbackOwner + "." + localMethod.getName().getIdentifier(); + } + } + } + if (resolvedPathMethod != null && resolvedPathMethod.contains(".")) { + String ownerFqn = resolvedPathMethod.substring(0, resolvedPathMethod.lastIndexOf('.')); + String methodName = resolvedPathMethod.substring(resolvedPathMethod.lastIndexOf('.') + 1); + TypeDeclaration pathOwnerType = context.getTypeDeclaration(ownerFqn); + MethodDeclaration pathMethodDeclaration = pathOwnerType != null + ? context.findMethodDeclaration(pathOwnerType, methodName, true) + : null; + if (hasEnumExpansionHint(pathMethodDeclaration)) { + expansionHint[0] = true; + String hintedEnumType = inferEnumTypeFromMethod(pathMethodDeclaration); + if (hintedEnumType != null) { + for (String expanded : expandDeclaredEnumValues(hintedEnumType)) { + addResolvedConstantToList(expanded, resolved); + } + } + } + for (String candidate : constantExtractor.resolveMethodReturnConstant( + ownerFqn, methodName, 0, new HashSet<>(), null)) { + addResolvedConstantToList(candidate, resolved); + } + } + for (String candidate : resolveLocalSwitchMethodConstants(callerType, localMethod)) { + addResolvedConstantToList(candidate, resolved); + } + } + String directResolved = constantResolver.resolve(candidateExpr, context); + if (directResolved != null) { + addResolvedConstantToList(directResolved, resolved); + } + List constants = new ArrayList<>(); + constantExtractor.extractConstantsFromExpression(candidateExpr, constants); + for (String candidate : constants) { + addResolvedConstantToList(candidate, resolved); + } + } + return super.visit(node); + } + }); + List distinct = resolved.stream().distinct().collect(java.util.stream.Collectors.toList()); + if (distinct.stream().noneMatch(AbstractCallGraphEngine.this::looksLikeEnumConstant)) { + for (int i = Math.max(0, path.size() - 2); i >= 0; i--) { + String pathMethod = path.get(i); + if (pathMethod == null || !pathMethod.contains(".")) { + continue; + } + String ownerFqn = pathMethod.substring(0, pathMethod.lastIndexOf('.')); + String methodName = pathMethod.substring(pathMethod.lastIndexOf('.') + 1); + for (String candidate : constantExtractor.resolveMethodReturnConstant( + ownerFqn, methodName, 0, new HashSet<>(), null)) { + addResolvedConstantToList(candidate, distinct); + } + } + } + if ((expansionHint[0] || hasMultiClassPolymorphicPath(path)) + && distinct.stream().noneMatch(AbstractCallGraphEngine.this::looksLikeEnumConstant)) { + List expanded = expandSingleSymbolicType(distinct); + if (expanded.isEmpty() && hasMultiClassPolymorphicPath(path)) { + expanded = inferEnumValuesFromPath(path); + } + if (!expanded.isEmpty()) { + return expanded; + } + } + return distinct; + } + + private List inferEnumValuesFromPath(List path) { + if (path == null || path.isEmpty()) { + return List.of(); + } + for (int i = path.size() - 1; i >= 0; i--) { + String methodFqn = path.get(i); + if (methodFqn == null || !methodFqn.contains(".")) { + continue; + } + String ownerFqn = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); + TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn); + MethodDeclaration methodDeclaration = ownerType != null + ? context.findMethodDeclaration(ownerType, methodName, true) + : null; + String enumType = inferEnumTypeFromMethod(methodDeclaration); + if (enumType == null && ownerType != null) { + for (MethodDeclaration candidateMethod : ownerType.getMethods()) { + enumType = inferEnumTypeFromMethod(candidateMethod); + if (enumType != null) { + break; + } + } + } + if (enumType != null) { + List expanded = expandDeclaredEnumValues(enumType); + if (!expanded.isEmpty()) { + return expanded; + } + } + } + return List.of(); + } + + private String inferEnumTypeFromMethod(MethodDeclaration methodDeclaration) { + if (methodDeclaration == null || methodDeclaration.getBody() == null) { + return null; + } + final String[] enumType = {null}; + methodDeclaration.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (!"valueOf".equals(node.getName().getIdentifier()) || node.getExpression() == null) { + return super.visit(node); + } + String receiver = node.getExpression().toString(); + if (receiver != null && !receiver.isBlank() && Character.isUpperCase(receiver.charAt(0))) { + enumType[0] = receiver; + return false; + } + return super.visit(node); + } + }); + return enumType[0]; + } + + private boolean hasMultiClassPolymorphicPath(List path) { + if (path == null || path.isEmpty()) { + return false; + } + Set classes = new LinkedHashSet<>(); + for (String methodFqn : path) { + if (methodFqn == null || !methodFqn.contains(".")) { + continue; + } + classes.add(methodFqn.substring(0, methodFqn.lastIndexOf('.'))); + } + return classes.size() > 2; + } + + private String findPrecedingConcretePathClass(List path, String callerClass) { + if (path == null || callerClass == null) { + return null; + } + for (int i = path.size() - 1; i >= 0; i--) { + String methodFqn = path.get(i); + if (methodFqn == null || !methodFqn.contains(".")) { + continue; + } + String classFqn = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + if (!callerClass.equals(classFqn)) { + return classFqn; + } + } + return null; + } + + private boolean hasEnumExpansionHint(MethodDeclaration methodDeclaration) { + if (methodDeclaration == null || methodDeclaration.getBody() == null) { + return false; + } + final boolean[] hint = {false}; + methodDeclaration.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + String name = node.getName().getIdentifier(); + if ("name".equals(name) || "toUpperCase".equals(name)) { + hint[0] = true; + return false; + } + return super.visit(node); + } + }); + return hint[0]; + } + + private String findPathMethodByName(List path, String methodName) { + if (path == null || methodName == null) { + return null; + } + for (int i = path.size() - 1; i >= 0; i--) { + String methodFqn = path.get(i); + if (methodFqn != null && methodFqn.endsWith("." + methodName)) { + return methodFqn; + } + } + return null; + } + + private List expandSingleSymbolicType(List values) { + if (values == null || values.isEmpty()) { + return List.of(); + } + Set normalizedTypes = new LinkedHashSet<>(); + String preferredType = null; + for (String value : values) { + if (value == null || !value.startsWith("")) { + continue; + } + String symbolicType = value.substring("")) { + if (!results.contains(resolved)) { + results.add(resolved); + } + return; + } String parsed = constantExtractor.parseEnumSetElement(resolved); if (!results.contains(parsed)) { results.add(parsed); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java index 016aace..610d136 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java @@ -48,7 +48,7 @@ public class ConstantExtractor { public void extractConstantsFromExpression(Expression expr, List constants) { if (constantResolver != null) { String resolved = constantResolver.resolve(expr, context); - if (resolved != null) { + if (resolved != null && !shouldInspectExpressionStructure(expr, resolved)) { addResolvedConstant(resolved, constants); return; } @@ -66,23 +66,7 @@ public class ConstantExtractor { } else if (expr instanceof Assignment assignment) { extractConstantsFromExpression(assignment.getRightHandSide(), constants); } else if (expr instanceof SwitchExpression se) { - se.accept(new ASTVisitor() { - @Override - public boolean visit(YieldStatement ys) { - extractConstantsFromExpression(ys.getExpression(), constants); - return super.visit(ys); - } - @Override - public boolean visit(ExpressionStatement es) { - extractConstantsFromExpression(es.getExpression(), constants); - return super.visit(es); - } - @Override - public boolean visit(ReturnStatement rs) { - extractConstantsFromExpression(rs.getExpression(), constants); - return super.visit(rs); - } - }); + extractConstantsFromSwitchLike(se, constants); } else if (expr instanceof ArrayAccess aa) { extractConstantsFromExpression(aa.getArray(), constants); } else if (expr instanceof ArrayCreation ac && ac.getInitializer() != null) { @@ -236,6 +220,17 @@ public class ConstantExtractor { } } + private boolean shouldInspectExpressionStructure(Expression expr, String resolved) { + if (expr == null || resolved == null) { + return false; + } + if (!(resolved.startsWith(""))) { + return false; + } + return expr instanceof SwitchExpression + || expr instanceof ConditionalExpression; + } + public void extractConstantsFromArgument(Expression argObj, List constants) { if (argObj instanceof ClassInstanceCreation innerCic) { String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(innerCic.getType()); @@ -382,6 +377,11 @@ public class ConstantExtractor { if (td != null) { MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); if (md != null && md.getBody() != null) { + for (Object stmtObj : md.getBody().statements()) { + if (stmtObj instanceof SwitchStatement switchStatement) { + extractConstantsFromSwitchStatement(switchStatement, constants); + } + } md.getBody().accept(new ASTVisitor() { @Override public boolean visit(ReturnStatement node) { @@ -441,7 +441,13 @@ public class ConstantExtractor { if (val.startsWith("ENUM_SET:")) { for (String eVal : val.substring(9).split(",")) { String parsed = parseEnumSetElement(eVal); - if (!constants.contains(parsed)) constants.add(parsed); + if (!constants.contains(parsed)) { + constants.add(parsed); + } + } + } else if (val.startsWith("")) { + if (!constants.contains(val)) { + constants.add(val); } } else { if (!constants.contains(val)) constants.add(val); @@ -559,9 +565,41 @@ public class ConstantExtractor { } public String parseEnumSetElement(String eVal) { + if (eVal != null && (eVal.startsWith(""))) { + return eVal; + } return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal; } + public void extractConstantsFromSwitchStatement(SwitchStatement switchStatement, List constants) { + if (switchStatement == null) { + return; + } + extractConstantsFromSwitchLike(switchStatement, constants); + } + + private void extractConstantsFromSwitchLike(ASTNode switchNode, List constants) { + switchNode.accept(new ASTVisitor() { + @Override + public boolean visit(YieldStatement ys) { + extractConstantsFromExpression(ys.getExpression(), constants); + return super.visit(ys); + } + + @Override + public boolean visit(ExpressionStatement es) { + extractConstantsFromExpression(es.getExpression(), constants); + return super.visit(es); + } + + @Override + public boolean visit(ReturnStatement rs) { + extractConstantsFromExpression(rs.getExpression(), constants); + return super.visit(rs); + } + }); + } + private boolean isKeyedLookupMethod(MethodInvocation mi) { if (expressionAccessClassifier != null) { return expressionAccessClassifier.isKeyedLookup(mi, null); 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 c906b14..b3f3f37 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 @@ -101,15 +101,31 @@ public class EnrichmentService { Map byEntryPoint = new LinkedHashMap<>(); if (existing != null) { for (CallChain chain : existing) { - byEntryPoint.putIfAbsent(entryPointKey(chain.getEntryPoint()), chain); + byEntryPoint.putIfAbsent(callChainKey(chain), chain); } } for (CallChain chain : refreshed) { - byEntryPoint.put(entryPointKey(chain.getEntryPoint()), chain); + 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 ""; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java index e790188..c2a3202 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java @@ -416,9 +416,6 @@ public final class AnalysisCanonicalFormValidator { List transitions, String eventTypeFqn, List violations) { - if (trigger.isExternal() || trigger.isAmbiguous()) { - return; - } if (LifecycleTriggerMarkers.isLifecycle(trigger.getEvent())) { return; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java index 328c4b1..17eb861 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java @@ -201,8 +201,10 @@ public class ExportService { && click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory .contextContainsAnalysisSources(result, context) && result.getMetadata() != null - && result.getMetadata().getEntryPoints() != null - && !result.getMetadata().getEntryPoints().isEmpty()) { + && ((result.getMetadata().getEntryPoints() != null + && !result.getMetadata().getEntryPoints().isEmpty()) + || (result.getMetadata().getCallChains() != null + && !result.getMetadata().getCallChains().isEmpty()))) { JdtIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, sourceRoot); enrichmentService.refreshCallChainsAndRelink(result, context, intelligence); } else { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicatePolymorphicInferenceTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicatePolymorphicInferenceTest.java index 238260e..3bee5ac 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicatePolymorphicInferenceTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicatePolymorphicInferenceTest.java @@ -51,6 +51,37 @@ class EnumMemberPredicatePolymorphicInferenceTest { assertThat(filtered).containsExactly("com.example.order.OrderCommand.PAY"); } + @Test + void shouldFilterEnumConstantsBySwitchBasedPredicate(@TempDir Path tempDir) throws Exception { + CodebaseContext context = scanEnumProject(tempDir, """ + package com.example.order; + public enum OrderCommand { + PAY, + SHIP, + META; + public boolean isEvent() { + return switch (this) { + case PAY, SHIP -> true; + default -> false; + }; + } + } + """); + + List filtered = EnumMemberPredicateEvaluator.filterEnumConstants( + List.of( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.SHIP", + "com.example.order.OrderCommand.META"), + "eventType.isEvent()", + "com.example.order.OrderCommand", + context); + + assertThat(filtered).containsExactly( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.SHIP"); + } + @Test void shouldPreferTransitionEventsOverFullEnumOnDynamicTrigger(@TempDir Path tempDir) throws Exception { CodebaseContext context = scanEnumProject(tempDir, """ @@ -380,6 +411,30 @@ class EnumMemberPredicatePolymorphicInferenceTest { assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2); } + @Test + void shouldEvaluateDirectFieldReturnInPredicateMethod(@TempDir Path tempDir) throws Exception { + CodebaseContext context = scanEnumProject(tempDir, """ + package com.example.order; + public enum OrderCommand { + PAY(true), + META(false); + private final boolean event; + OrderCommand(boolean event) { this.event = event; } + public boolean isEvent() { return event; } + } + """); + + List filtered = EnumMemberPredicateEvaluator.filterEnumConstants( + List.of( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.META"), + "eventType.isEvent()", + "com.example.order.OrderCommand", + context); + + assertThat(filtered).containsExactly("com.example.order.OrderCommand.PAY"); + } + @Test void shouldNotMatchNonEventConstantsWhenPolyListWasOverBroad(@TempDir Path tempDir) throws Exception { CodebaseContext context = scanEnumProject(tempDir, """ diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java index 3d33ea2..f93e40a 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java @@ -474,6 +474,54 @@ class CentralDispatcherResolutionTest { "OrderEvent.SHIP"); } + @Test + void staticRoutingHelperShouldForwardLiteralsToCentralDispatcher(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + CommandGateway gateway; + public void pay() { gateway.routeAndPay(); } + public void ship() { gateway.routeAndShip(); } + } + class CommandGateway { + CentralDispatcher dispatcher; + void routeAndPay() { dispatcher.dispatch("ORDER", OrderRoutingHelper.payAction()); } + void routeAndShip() { dispatcher.dispatch("ORDER", OrderRoutingHelper.shipAction()); } + } + class OrderRoutingHelper { + static String payAction() { return "PAY"; } + static String shipAction() { return "SHIP"; } + } + class CentralDispatcher { + void dispatch(String domain, String action) { + StateMachine sm = new StateMachine(); + sm.sendEvent(OrderEvent.valueOf(action)); + } + } + enum OrderEvent { PAY, SHIP } + class StateMachine { public void sendEvent(OrderEvent e) {} } + class OrderStateMachineConfig {} + """; + CodebaseContext context = scanSource(source, tempDir); + + CallChain payChain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE); + CallChain shipChain = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE); + + assertPolyEvents(payChain, "OrderEvent.PAY"); + assertPolyEvents(shipChain, "OrderEvent.SHIP"); + + assertLinkedEvent( + linkEndpointToTransition(payChain, MACHINE_CONFIG, + transition("NEW", "PAID", "OrderEvent.PAY"), + transition("PAID", "SHIPPED", "OrderEvent.SHIP")), + "OrderEvent.PAY"); + assertLinkedEvent( + linkEndpointToTransition(shipChain, MACHINE_CONFIG, + transition("NEW", "PAID", "OrderEvent.PAY"), + transition("PAID", "SHIPPED", "OrderEvent.SHIP")), + "OrderEvent.SHIP"); + } + /** * Tier 1 — DTO field set via constructor in controller, read via getter in gateway. * diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JsonRoundTripCanonicalizationTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JsonRoundTripCanonicalizationTest.java index 79130bb..b61184f 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JsonRoundTripCanonicalizationTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JsonRoundTripCanonicalizationTest.java @@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.service; import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.exporter.ExportOptions; import click.kamil.springstatemachineexporter.exporter.JsonExporter; @@ -145,6 +146,60 @@ class JsonRoundTripCanonicalizationTest { .isEqualTo("com.example.order.OrderState.NEW"); } + @Test + void shouldRefreshCallChainsWhenJsonLacksTopLevelEntryPoints(@TempDir Path tempDir) throws Exception { + writeSampleProject(tempDir); + + CallChain chain = CallChain.builder() + .entryPoint(EntryPoint.builder() + .type(EntryPoint.Type.CUSTOM) + .className("com.example.web.OrderController") + .methodName("pay") + .sourceFile("OrderController.java") + .build()) + .triggerPoint(TriggerPoint.builder() + .event("event") + .className("com.example.web.OrderStateMachine") + .methodName("sendEvent") + .sourceFile("OrderStateMachine.java") + .build()) + .build(); + + AnalysisResult shortForm = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .transitions(List.of(shortTransition( + "OrderEvent.PAY", + "OrderState.NEW", + "OrderState.PAID"))) + .metadata(CodebaseMetadata.builder() + .callChains(List.of(chain)) + .build()) + .build(); + + Path jsonFile = tempDir.resolve("machine.json"); + Files.writeString(jsonFile, new JsonExporter().export(shortForm, ExportOptions.builder().build())); + + Path outputDir = tempDir.resolve("out"); + ExportService exportService = new ExportService(List.of(new JsonExporter())); + exportService.runJsonExporter(jsonFile, outputDir, List.of("json")); + + AnalysisResult roundTripped = new JsonImportService().importAnalysisResult( + outputDir.resolve("com.example.config.OrderStateMachineConfiguration") + .resolve("com.example.config.OrderStateMachineConfiguration.json")); + + assertThat(roundTripped.getMetadata().getCallChains()).hasSize(1); + assertThat(roundTripped.getMetadata().getCallChains().get(0).getEntryPoint()).isNotNull(); + assertThat(roundTripped.getMetadata().getCallChains().get(0).getEntryPoint().getClassName()) + .isEqualTo("com.example.web.OrderController"); + assertThat(roundTripped.getMetadata().getCallChains().get(0).getEntryPoint().getMethodName()) + .isEqualTo("pay"); + assertThat(roundTripped.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); + assertThat(roundTripped.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent()) + .isEqualTo("com.example.order.OrderEvent.PAY"); + } + @Test void shouldDedupeStatesByFullIdentifierAfterPropertyResolution(@TempDir Path tempDir) throws Exception { writeSampleProject(tempDir); @@ -189,8 +244,10 @@ class JsonRoundTripCanonicalizationTest { Path javaRoot = projectRoot.resolve("src/main/java"); Path orderPkg = javaRoot.resolve("com/example/order"); Path configPkg = javaRoot.resolve("com/example/config"); + Path webPkg = javaRoot.resolve("com/example/web"); Files.createDirectories(orderPkg); Files.createDirectories(configPkg); + Files.createDirectories(webPkg); Files.writeString(projectRoot.resolve("build.gradle"), "plugins { id 'java' }"); Files.writeString(orderPkg.resolve("OrderState.java"), "package com.example.order; public enum OrderState { NEW, PAID }"); @@ -206,5 +263,25 @@ class JsonRoundTripCanonicalizationTest { extends EnumStateMachineConfigurerAdapter { } """); + Files.writeString(webPkg.resolve("OrderController.java"), + """ + package com.example.web; + import com.example.order.OrderEvent; + public class OrderController { + private final OrderStateMachine stateMachine = new OrderStateMachine(); + public void pay() { + stateMachine.sendEvent(OrderEvent.PAY); + } + } + """); + Files.writeString(webPkg.resolve("OrderStateMachine.java"), + """ + package com.example.web; + import com.example.order.OrderEvent; + public class OrderStateMachine { + public void sendEvent(OrderEvent event) { + } + } + """); } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidatorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidatorTest.java index 189e862..4d99a5a 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidatorTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidatorTest.java @@ -286,6 +286,42 @@ class AnalysisCanonicalFormValidatorTest { .contains("metadata.callChains[0].matchedTransitions"); } + @Test + void shouldFailWhenExternalAmbiguousTriggerHasResolvablePolymorphicEventsButNoMatchedTransitions() { + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .transitions(List.of(transition( + "com.example.order.OrderEvent.PAY", + "com.example.order.OrderState.NEW", + "com.example.order.OrderState.PAID"))) + .metadata(CodebaseMetadata.builder() + .callChains(List.of(CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("eventType") + .className("com.example.web.OrderController") + .methodName("dispatch") + .sourceFile("OrderController.java") + .polymorphicEvents(List.of("com.example.order.OrderEvent.PAY")) + .external(true) + .ambiguous(true) + .build()) + .build())) + .build()) + .build(); + + List violations = + AnalysisCanonicalFormValidator.validateWithMachineTypes( + result, + new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes( + "com.example.order.OrderState", "com.example.order.OrderEvent")); + + assertThat(violations) + .extracting(AnalysisCanonicalFormValidator.Violation::path) + .contains("metadata.callChains[0].matchedTransitions"); + } + @Test void shouldAllowSymbolicPolymorphicEventsWithoutMatchedTransitions() { AnalysisResult result = AnalysisResult.builder()