diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationFactPropagator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationFactPropagator.java index e98a654..7ad10c4 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationFactPropagator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationFactPropagator.java @@ -349,7 +349,11 @@ public final class AllocationFactPropagator { sites.addAll(nested.sites()); } } - if (writeCount == 0 || sites.isEmpty()) { + if (writeCount == 0) { + // Unique ctor / @PostConstruct write on the owner type (path-independent init). + return factsFromOwnerFieldInit(owner, fieldName, methodPath, callGraph, depth, visited); + } + if (sites.isEmpty()) { return ReceiverFacts.empty(); } // Ambiguous: multiple writes that disagree → fail closed via ofAll size; also reject @@ -363,6 +367,71 @@ public final class AllocationFactPropagator { } } + /** + * Exactly one field write across constructors and {@code @PostConstruct} methods on {@code owner}. + */ + private ReceiverFacts factsFromOwnerFieldInit( + TypeDeclaration owner, + String fieldName, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + if (owner == null || fieldName == null) { + return ReceiverFacts.empty(); + } + LinkedHashSet sites = new LinkedHashSet<>(); + int writeCount = 0; + for (FieldDeclaration fd : owner.getFields()) { + for (Object fragObj : fd.fragments()) { + if (fragObj instanceof VariableDeclarationFragment frag + && fieldName.equals(frag.getName().getIdentifier()) + && frag.getInitializer() != null) { + writeCount++; + ReceiverFacts nested = factsAtRaw( + frag.getInitializer(), null, methodPath, callGraph, depth + 1, visited); + sites.addAll(nested.sites()); + } + } + } + for (MethodDeclaration md : owner.getMethods()) { + if (md.getBody() == null) { + continue; + } + if (!md.isConstructor() && !hasPostConstructAnnotation(md)) { + continue; + } + List rhsList = collectFieldWrites(md, fieldName); + for (Expression rhs : rhsList) { + writeCount++; + ReceiverFacts nested = factsAtRaw(rhs, md, methodPath, callGraph, depth + 1, visited); + sites.addAll(nested.sites()); + } + } + if (writeCount != 1 || sites.isEmpty()) { + return ReceiverFacts.empty(); + } + return ReceiverFacts.ofAll(sites); + } + + private static boolean hasPostConstructAnnotation(MethodDeclaration md) { + if (md == null) { + return false; + } + for (Object modObj : md.modifiers()) { + if (modObj instanceof org.eclipse.jdt.core.dom.Annotation ann) { + String name = ann.getTypeName().getFullyQualifiedName(); + if ("PostConstruct".equals(name) + || name.endsWith(".PostConstruct") + || "javax.annotation.PostConstruct".equals(name) + || "jakarta.annotation.PostConstruct".equals(name)) { + return true; + } + } + } + return false; + } + /** * Unique {@code receiver.setX(arg)} / {@code setX(arg)} on path whose arg has allocation facts. */ diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrower.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrower.java index bef5df3..20d700c 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrower.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrower.java @@ -6,6 +6,7 @@ import org.eclipse.jdt.core.dom.ASTVisitor; import org.eclipse.jdt.core.dom.CastExpression; import org.eclipse.jdt.core.dom.Expression; import org.eclipse.jdt.core.dom.IfStatement; +import org.eclipse.jdt.core.dom.InfixExpression; import org.eclipse.jdt.core.dom.InstanceofExpression; import org.eclipse.jdt.core.dom.MethodDeclaration; import org.eclipse.jdt.core.dom.Modifier; @@ -17,8 +18,11 @@ import org.eclipse.jdt.core.dom.Statement; import org.eclipse.jdt.core.dom.TypeDeclaration; import org.eclipse.jdt.core.dom.TypePattern; +import java.util.ArrayList; +import java.util.List; + /** - * Dominating {@code instanceof} / pattern narrowing → unique concrete {@link AllocationSite}. + * Dominating {@code instanceof} / pattern / cast narrowing → unique concrete {@link AllocationSite}. * Shared by {@link AllocationFactPropagator}; fail-closed for else-branch, abstract targets, * and sends outside the narrow. */ @@ -66,14 +70,25 @@ public final class InstanceofNarrower { } /** - * Merge base AFP facts with dominating instanceof. - *
    - *
  • instanceof empty → base unchanged
  • - *
  • base empty → instanceof
  • - *
  • base unique is Object / missing TD / interface / abstract → refine to instanceof
  • - *
  • base unique concrete equals instanceof → that site
  • - *
  • base unique concrete conflicts with instanceof → empty (fail-closed)
  • - *
+ * Facts from a {@code (ConcreteType) expr} cast wrapping the receiver expression itself. + */ + public static ReceiverFacts factsFromCast(Expression receiver, CodebaseContext context) { + if (receiver == null || context == null) { + return ReceiverFacts.empty(); + } + Expression current = receiver; + while (current instanceof ParenthesizedExpression pe) { + current = pe.getExpression(); + } + if (current instanceof CastExpression ce && ce.getType() != null) { + return factsFromConcreteTypeName(ce.getType().toString(), context); + } + return ReceiverFacts.empty(); + } + + /** + * Merge base AFP facts with dominating instanceof and/or cast. + * Precedence: instanceof, then cast, then base. Concrete CIC conflict → empty. */ public static ReceiverFacts refine( ReceiverFacts base, @@ -81,22 +96,33 @@ public final class InstanceofNarrower { ASTNode useSite, CodebaseContext context) { ReceiverFacts narrowed = factsAt(receiver, useSite, context); - if (narrowed.isEmpty()) { - return base == null ? ReceiverFacts.empty() : base; + if (!narrowed.isEmpty()) { + return mergeRefine(base, narrowed, context); } + ReceiverFacts fromCast = factsFromCast(receiver, context); + if (!fromCast.isEmpty()) { + return mergeRefine(base, fromCast, context); + } + return base == null ? ReceiverFacts.empty() : base; + } + + private static ReceiverFacts mergeRefine( + ReceiverFacts base, + ReceiverFacts refined, + CodebaseContext context) { if (base == null || base.isEmpty()) { - return narrowed; + return refined; } AllocationSite baseUnique = base.uniqueOrNull(); - AllocationSite narrowUnique = narrowed.uniqueOrNull(); - if (baseUnique == null || narrowUnique == null) { + AllocationSite refineUnique = refined.uniqueOrNull(); + if (baseUnique == null || refineUnique == null) { return ReceiverFacts.empty(); } if (isWeakAllocSite(baseUnique, context)) { - return narrowed; + return refined; } - if (sameType(baseUnique.typeFqn(), narrowUnique.typeFqn())) { - return ReceiverFacts.of(baseUnique.preferRich(narrowUnique)); + if (sameType(baseUnique.typeFqn(), refineUnique.typeFqn())) { + return ReceiverFacts.of(baseUnique.preferRich(refineUnique)); } return ReceiverFacts.empty(); } @@ -158,6 +184,41 @@ public final class InstanceofNarrower { Expression condition, String recvName, CodebaseContext context) { + for (Expression part : flattenAndConditions(condition)) { + ReceiverFacts fromPart = extractSingleInstanceof(part, recvName, context); + if (!fromPart.isEmpty()) { + return fromPart; + } + } + return ReceiverFacts.empty(); + } + + private static List flattenAndConditions(Expression condition) { + List parts = new ArrayList<>(); + collectAndParts(condition, parts); + if (parts.isEmpty() && condition != null) { + parts.add(condition); + } + return parts; + } + + private static void collectAndParts(Expression expression, List parts) { + Expression cond = peel(expression); + if (cond instanceof InfixExpression infix + && infix.getOperator() == InfixExpression.Operator.CONDITIONAL_AND) { + collectAndParts(infix.getLeftOperand(), parts); + collectAndParts(infix.getRightOperand(), parts); + return; + } + if (cond != null) { + parts.add(cond); + } + } + + private static ReceiverFacts extractSingleInstanceof( + Expression condition, + String recvName, + CodebaseContext context) { Expression cond = peel(condition); if (cond instanceof InstanceofExpression io) { Expression left = peel(io.getLeftOperand()); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolver.java index 2ccb6a1..d776905 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolver.java @@ -11,6 +11,7 @@ import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapReg import click.kamil.springstatemachineexporter.analysis.pipeline.MessageCarrierSupport; import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper; import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport; +import click.kamil.springstatemachineexporter.analysis.pipeline.SwitchConstSupport; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.service.FunctionalInterfaceTypes; import click.kamil.springstatemachineexporter.analysis.service.VariableTracer; @@ -22,6 +23,8 @@ import org.eclipse.jdt.core.dom.CastExpression; import org.eclipse.jdt.core.dom.ClassInstanceCreation; import org.eclipse.jdt.core.dom.ConditionalExpression; import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.ExpressionMethodReference; +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.ITypeBinding; @@ -39,10 +42,14 @@ import org.eclipse.jdt.core.dom.SimpleName; import org.eclipse.jdt.core.dom.SingleVariableDeclaration; import org.eclipse.jdt.core.dom.StringLiteral; 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.TypeMethodReference; import org.eclipse.jdt.core.dom.VariableDeclarationFragment; import org.eclipse.jdt.core.dom.VariableDeclarationStatement; +import org.eclipse.jdt.core.dom.YieldStatement; import java.util.ArrayList; import java.util.HashMap; @@ -335,6 +342,28 @@ public final class EventIdentityResolver { return List.of(); } + // 2d. Switch expression: selector maps to unique arm, or all arms agree + if (peeled instanceof SwitchExpression se) { + return resolveFromSwitchExpression(se, methodPath, callGraph, depth, visited); + } + + // 2e. Optional.orElse(CONST) — both present side and else must agree + if (peeled instanceof MethodInvocation orElseMi + && "orElse".equals(orElseMi.getName().getIdentifier()) + && orElseMi.arguments().size() == 1 + && orElseMi.getExpression() != null + && orElseMi.arguments().get(0) instanceof Expression elseArg) { + List elseArm = resolveUnique(elseArg, methodPath, callGraph, depth + 1, visited); + Expression present = peelOptionalPresent(orElseMi.getExpression()); + if (present != null && elseArm.size() == 1) { + List presentArm = resolveUnique(present, methodPath, callGraph, depth + 1, visited); + if (presentArm.size() == 1 && presentArm.get(0).equals(elseArm.get(0))) { + return presentArm; + } + } + return List.of(); + } + // 3. Enum.valueOf(expr) or Enum.valueOf(Class, String) if (peeled instanceof MethodInvocation valueOfMi && "valueOf".equals(valueOfMi.getName().getIdentifier())) { @@ -512,7 +541,7 @@ public final class EventIdentityResolver { if (!visited.add(key) || helperCall.arguments().isEmpty()) { return List.of(); } - MethodDeclaration helperMd = resolveHelperMethod(helperCall); + MethodDeclaration helperMd = resolveHelperMethod(helperCall, methodPath); if (helperMd == null) { return List.of(); } @@ -538,7 +567,7 @@ public final class EventIdentityResolver { if (!visited.add(key) || helperCall.arguments().isEmpty()) { return List.of(); } - MethodDeclaration helperMd = resolveHelperMethod(helperCall); + MethodDeclaration helperMd = resolveHelperMethod(helperCall, methodPath); if (helperMd == null || helperMd.getBody() == null || helperMd.parameters().isEmpty()) { return List.of(); } @@ -572,6 +601,93 @@ public final class EventIdentityResolver { return looksLikeEnumConstant(mapped) ? List.of(mapped) : List.of(); } + private List resolveFromSwitchExpression( + SwitchExpression se, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + String key = "swexpr:" + System.identityHashCode(se); + if (!visited.add(key)) { + return List.of(); + } + // All arms (including default) must agree for a path-insensitive unique proof. + // Throw-only / missing payloads are opaque — never unique-agree on a partial set. + if (!SwitchConstSupport.hasOpaqueArm(se)) { + LinkedHashSet allArms = new LinkedHashSet<>(); + for (Expression armExpr : SwitchConstSupport.armPayloadExpressions(se)) { + String constVal = resolveReturnConstant(armExpr); + if (constVal != null) { + allArms.add(constVal); + } else { + allArms.add("__opaque__" + System.identityHashCode(armExpr)); + } + } + if (allArms.size() == 1) { + String only = allArms.iterator().next(); + if (only.startsWith("__opaque__")) { + return List.of(); + } + String normalized = VirtualAccessorConstResolver.normalizeConstant(only); + if (normalized != null && looksLikeEnumConstant(normalized)) { + return List.of(normalized); + } + return looksLikeEnumConstant(only) ? List.of(only) : List.of(); + } + } + Map caseToConst = new HashMap<>(); + ingestSwitchCases(se.statements(), caseToConst); + if (caseToConst.isEmpty()) { + return List.of(); + } + List selectorKeys = resolveUnique(se.getExpression(), methodPath, callGraph, depth + 1, visited); + if (selectorKeys.size() != 1) { + return List.of(); + } + String keyConst = VirtualAccessorConstResolver.normalizeConstant(selectorKeys.get(0)); + if (keyConst == null) { + keyConst = selectorKeys.get(0); + } + if (keyConst.contains(".")) { + keyConst = keyConst.substring(keyConst.lastIndexOf('.') + 1); + } + String mapped = caseToConst.get(keyConst); + if (mapped == null) { + mapped = caseToConst.get(selectorKeys.get(0)); + } + if (mapped == null) { + return List.of(); + } + String normalized = VirtualAccessorConstResolver.normalizeConstant(mapped); + if (normalized != null && looksLikeEnumConstant(normalized)) { + return List.of(normalized); + } + return looksLikeEnumConstant(mapped) ? List.of(mapped) : List.of(); + } + + private static Expression peelOptionalPresent(Expression optionalExpr) { + Expression current = peel(optionalExpr); + if (current instanceof MethodInvocation mi + && "of".equals(mi.getName().getIdentifier()) + && mi.arguments().size() == 1 + && mi.arguments().get(0) instanceof Expression arg) { + String recvText = mi.getExpression() == null ? "" : mi.getExpression().toString(); + if (recvText.endsWith("Optional") || recvText.contains("Optional.")) { + return peel(arg); + } + } + if (current instanceof MethodInvocation mi + && "ofNullable".equals(mi.getName().getIdentifier()) + && mi.arguments().size() == 1 + && mi.arguments().get(0) instanceof Expression arg) { + String recvText = mi.getExpression() == null ? "" : mi.getExpression().toString(); + if (recvText.endsWith("Optional") || recvText.contains("Optional.")) { + return peel(arg); + } + } + return null; + } + private Map extractStringCaseMap(MethodDeclaration method) { Map map = new HashMap<>(); if (method.getBody() == null) { @@ -580,35 +696,13 @@ public final class EventIdentityResolver { method.getBody().accept(new ASTVisitor() { @Override public boolean visit(SwitchStatement node) { - String currentKey = null; - for (Object stmtObj : node.statements()) { - if (stmtObj instanceof SwitchCase sc) { - if (sc.isDefault()) { - currentKey = null; - continue; - } - currentKey = null; - for (Object exprObj : sc.expressions()) { - if (exprObj instanceof StringLiteral sl) { - currentKey = sl.getLiteralValue(); - } else if (exprObj instanceof Expression ex && constantResolver != null) { - String resolved = constantResolver.resolve(ex, context); - if (resolved != null) { - currentKey = VirtualAccessorConstResolver.normalizeConstant(resolved); - if (currentKey != null && currentKey.contains(".")) { - currentKey = currentKey.substring(currentKey.lastIndexOf('.') + 1); - } - } - } - } - } else if (stmtObj instanceof ReturnStatement rs && currentKey != null) { - String constVal = resolveReturnConstant(rs.getExpression()); - if (constVal != null) { - map.put(currentKey, constVal); - } - currentKey = null; - } - } + ingestSwitchCases(node.statements(), map); + return false; + } + + @Override + public boolean visit(SwitchExpression node) { + ingestSwitchCases(node.statements(), map); return false; } @@ -633,11 +727,45 @@ public final class EventIdentityResolver { return map; } + private void ingestSwitchCases(List statements, Map map) { + Map payloads = SwitchConstSupport.stringCasePayloads( + statements, + this::resolveSwitchCaseKey); + for (Map.Entry entry : payloads.entrySet()) { + String constVal = resolveReturnConstant(entry.getValue()); + if (constVal != null) { + map.put(entry.getKey(), constVal); + } + } + } + + private String resolveSwitchCaseKey(Expression caseExpr) { + if (caseExpr == null || constantResolver == null) { + return null; + } + String resolved = constantResolver.resolve(caseExpr, context); + if (resolved == null) { + return null; + } + String currentKey = VirtualAccessorConstResolver.normalizeConstant(resolved); + if (currentKey != null && currentKey.contains(".")) { + currentKey = currentKey.substring(currentKey.lastIndexOf('.') + 1); + } + return currentKey; + } + private String resolveReturnConstant(Expression expression) { - if (expression == null || constantResolver == null) { + if (expression == null) { return null; } Expression peeled = peel(expression); + if (peeled instanceof StringLiteral sl) { + String lit = sl.getLiteralValue(); + return looksLikeEnumConstant(lit) ? lit : null; + } + if (constantResolver == null) { + return null; + } String direct = constantResolver.resolve(peeled, context); String normalized = VirtualAccessorConstResolver.normalizeConstant(direct); if (normalized != null && looksLikeEnumConstant(normalized)) { @@ -791,7 +919,7 @@ public final class EventIdentityResolver { if (!visited.add(key) || helperCall.arguments().isEmpty()) { return List.of(); } - MethodDeclaration helperMd = resolveHelperMethod(helperCall); + MethodDeclaration helperMd = resolveHelperMethod(helperCall, methodPath); if (helperMd == null || helperMd.getBody() == null) { return List.of(); } @@ -983,6 +1111,10 @@ public final class EventIdentityResolver { } private MethodDeclaration resolveHelperMethod(MethodInvocation call) { + return resolveHelperMethod(call, null); + } + + private MethodDeclaration resolveHelperMethod(MethodInvocation call, List methodPath) { String methodName = call.getName().getIdentifier(); TypeDeclaration owner = null; if (call.getExpression() == null) { @@ -999,25 +1131,41 @@ public final class EventIdentityResolver { if (owner == null) { owner = findEnclosingType(call); } - if (owner == null) { - return null; - } - MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true); - if (md != null) { - return md; - } - String superFqn = context.getSuperclassFqn(owner); - int hops = 0; - while (superFqn != null && hops++ < 8) { - TypeDeclaration superTd = context.getTypeDeclaration(superFqn); - if (superTd == null) { - break; - } - md = context.findMethodDeclaration(superTd, methodName, true); + if (owner != null) { + MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true); if (md != null) { return md; } - superFqn = context.getSuperclassFqn(superTd); + String superFqn = context.getSuperclassFqn(owner); + int hops = 0; + while (superFqn != null && hops++ < 8) { + TypeDeclaration superTd = context.getTypeDeclaration(superFqn); + if (superTd == null) { + break; + } + md = context.findMethodDeclaration(superTd, methodName, true); + if (md != null) { + return md; + } + superFqn = context.getSuperclassFqn(superTd); + } + } + // Detached / string-parsed invocations have no enclosing type — walk the call path. + if (methodPath != null) { + for (String methodFqn : methodPath) { + if (methodFqn == null || !methodFqn.contains(".")) { + continue; + } + String classFqn = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + TypeDeclaration td = context.getTypeDeclaration(classFqn); + if (td == null) { + continue; + } + MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); + if (md != null) { + return md; + } + } } return null; } @@ -1223,7 +1371,19 @@ public final class EventIdentityResolver { Map> callGraph, int depth, Set visited) { - Expression eventExpr = MessageCarrierSupport.peelEventFromMessageExpr(peeled); + Expression current = peeled; + if (current instanceof MethodInvocation mi + && "getPayload".equals(mi.getName().getIdentifier()) + && mi.arguments().isEmpty() + && mi.getExpression() != null) { + MethodDeclaration enclosing = findEnclosingMethod(mi); + Expression msg = peel(mi.getExpression()); + Expression builder = findMessageBuilderInitializer(msg, enclosing); + if (builder != null) { + current = builder; + } + } + Expression eventExpr = MessageCarrierSupport.peelEventFromMessageExpr(current); if (eventExpr == null || eventExpr == peeled) { return List.of(); } @@ -1249,12 +1409,149 @@ public final class EventIdentityResolver { } return List.of(); } + if (binding instanceof ExpressionMethodReference || binding instanceof TypeMethodReference) { + return resolveFromMethodReference(binding, methodPath, callGraph, depth, visited); + } if (binding != null && binding != getCall.getExpression()) { return resolveUnique(binding, methodPath, callGraph, depth + 1, visited); } return List.of(); } + private List resolveFromMethodReference( + Expression methodRef, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + String key = "mref:" + System.identityHashCode(methodRef); + if (!visited.add(key)) { + return List.of(); + } + MethodDeclaration md = resolveMethodReferenceTarget(methodRef); + if (md == null || md.getBody() == null) { + return List.of(); + } + LinkedHashSet proven = new LinkedHashSet<>(); + md.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(ReturnStatement node) { + List nested = resolveUnique( + node.getExpression(), methodPath, callGraph, depth + 1, visited); + if (nested.size() == 1) { + proven.add(nested.get(0)); + } else if (!nested.isEmpty()) { + proven.add("__multi__"); + } + return super.visit(node); + } + }); + proven.remove("__multi__"); + return proven.size() == 1 ? List.copyOf(proven) : List.of(); + } + + private MethodDeclaration resolveMethodReferenceTarget(Expression methodRef) { + if (methodRef instanceof ExpressionMethodReference emr) { + String methodName = emr.getName().getIdentifier(); + TypeDeclaration owner = typeOfExpression(emr.getExpression(), emr); + if (owner == null) { + owner = findEnclosingType(emr); + } + return owner == null ? null : context.findMethodDeclaration(owner, methodName, true); + } + if (methodRef instanceof TypeMethodReference tmr) { + String methodName = tmr.getName().getIdentifier(); + // Enum.valueOf etc. need an argument — not a 0-arg Supplier. + if ("valueOf".equals(methodName)) { + return null; + } + TypeDeclaration owner = context.getTypeDeclaration(tmr.getType().toString()); + if (owner == null) { + String simple = tmr.getType().toString(); + int generic = simple.indexOf('<'); + if (generic >= 0) { + simple = simple.substring(0, generic); + } + owner = context.getTypeDeclaration(simple); + } + return owner == null ? null : context.findMethodDeclaration(owner, methodName, true); + } + return null; + } + + private TypeDeclaration typeOfExpression(Expression expression, ASTNode site) { + Expression peeled = peel(expression); + if (peeled instanceof ThisExpression) { + return findEnclosingType(site); + } + if (peeled instanceof SimpleName sn) { + String name = sn.getIdentifier(); + MethodDeclaration enclosingMethod = findEnclosingMethod(site); + TypeDeclaration enclosingType = findEnclosingType(site); + if (enclosingMethod != null) { + for (Object paramObj : enclosingMethod.parameters()) { + if (paramObj instanceof SingleVariableDeclaration svd + && name.equals(svd.getName().getIdentifier()) + && svd.getType() != null) { + return typeDeclarationOfAstType(svd.getType().toString()); + } + } + if (enclosingMethod.getBody() != null) { + String[] localType = {null}; + enclosingMethod.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationStatement node) { + for (Object fragObj : node.fragments()) { + if (fragObj instanceof VariableDeclarationFragment frag + && name.equals(frag.getName().getIdentifier()) + && node.getType() != null) { + localType[0] = node.getType().toString(); + return false; + } + } + return super.visit(node); + } + }); + if (localType[0] != null) { + return typeDeclarationOfAstType(localType[0]); + } + } + } + if (enclosingType != null) { + for (FieldDeclaration fd : enclosingType.getFields()) { + for (Object fragObj : fd.fragments()) { + if (fragObj instanceof VariableDeclarationFragment frag + && name.equals(frag.getName().getIdentifier()) + && fd.getType() != null) { + return typeDeclarationOfAstType(fd.getType().toString()); + } + } + } + } + return context.getTypeDeclaration(name); + } + if (peeled instanceof Name name) { + return context.getTypeDeclaration(name.getFullyQualifiedName()); + } + return null; + } + + private TypeDeclaration typeDeclarationOfAstType(String typeName) { + if (typeName == null || typeName.isBlank()) { + return null; + } + int generic = typeName.indexOf('<'); + if (generic >= 0) { + typeName = typeName.substring(0, generic); + } + TypeDeclaration td = context.getTypeDeclaration(typeName); + if (td == null) { + String simple = typeName.contains(".") ? typeName.substring(typeName.lastIndexOf('.') + 1) : typeName; + td = context.getTypeDeclaration(simple); + } + return td; + } + private Expression findFunctionalBinding( Expression receiver, MethodDeclaration enclosing, @@ -1264,6 +1561,9 @@ public final class EventIdentityResolver { if (peeled instanceof LambdaExpression lambda) { return lambda; } + if (peeled instanceof ExpressionMethodReference || peeled instanceof TypeMethodReference) { + return peeled; + } if (!(peeled instanceof SimpleName sn) || enclosing == null) { return null; } @@ -1274,7 +1574,9 @@ public final class EventIdentityResolver { if (variableTracer != null) { for (Expression def : variableTracer.traceVariableAll(peeled)) { Expression defPeeled = peel(def); - if (defPeeled instanceof LambdaExpression) { + if (defPeeled instanceof LambdaExpression + || defPeeled instanceof ExpressionMethodReference + || defPeeled instanceof TypeMethodReference) { return defPeeled; } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/EnumExpansionHintSupport.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/EnumExpansionHintSupport.java new file mode 100644 index 0000000..be1a236 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/EnumExpansionHintSupport.java @@ -0,0 +1,64 @@ +package click.kamil.springstatemachineexporter.analysis.pipeline; + +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; + +/** + * Shared AST detection of dynamic string transforms that justify widening + * {@code } to concrete enum members (ACE poly fill). + *

+ * Qualifying invocations: zero-arg {@code name()} (Enum.name peel), and + * {@code toUpperCase} with any arity (including {@code Locale}). Does not match + * {@code getName()}, string/comment substrings, or other {@code *name*} methods. + */ +public final class EnumExpansionHintSupport { + + private EnumExpansionHintSupport() { + } + + public static boolean expressionHasHint(Expression expr) { + return nodeHasHint(expr); + } + + public static boolean methodBodyHasHint(MethodDeclaration methodDeclaration) { + if (methodDeclaration == null || methodDeclaration.getBody() == null) { + return false; + } + return nodeHasHint(methodDeclaration.getBody()); + } + + private static boolean nodeHasHint(ASTNode node) { + if (node == null) { + return false; + } + final boolean[] hint = {false}; + node.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation invocation) { + if (isQualifyingHint(invocation)) { + hint[0] = true; + return false; + } + return super.visit(invocation); + } + }); + return hint[0]; + } + + static boolean isQualifyingHint(MethodInvocation invocation) { + if (invocation == null || invocation.getName() == null) { + return false; + } + String name = invocation.getName().getIdentifier(); + if ("name".equals(name)) { + return invocation.arguments().isEmpty(); + } + if ("toUpperCase".equals(name)) { + return true; + } + return false; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupport.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupport.java index cfec4ad..bb104c9 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupport.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupport.java @@ -9,7 +9,7 @@ import org.eclipse.jdt.core.dom.StringLiteral; /** * Shared MessageBuilder / GenericMessage / allowlisted-header peels for event identity. - * Used by {@code EventIdentityResolver} and call-graph unwrap paths. + * Used by {@code EventIdentityResolver}, call-graph unwrap, and {@code GenericEventDetector}. */ public final class MessageCarrierSupport { @@ -19,6 +19,7 @@ public final class MessageCarrierSupport { /** * Prefer allowlisted {@code setHeader("event"|"smEvent", …)} on a MessageBuilder chain; * else peel {@code withPayload}/{@code just}/{@code success}; else GenericMessage ctor arg. + * Also peels {@code message.getPayload()} to the message expression first. * * @return the event-bearing expression, or null when nothing is proven */ @@ -27,6 +28,12 @@ public final class MessageCarrierSupport { return null; } Expression peeled = peel(expression); + if (peeled instanceof MethodInvocation mi + && "getPayload".equals(mi.getName().getIdentifier()) + && mi.arguments().isEmpty() + && mi.getExpression() != null) { + return peelEventFromMessageExpr(mi.getExpression()); + } Expression headerEvent = findSetHeaderValueOnChain(peeled, null); if (headerEvent != null) { return peel(headerEvent); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/SwitchConstSupport.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/SwitchConstSupport.java new file mode 100644 index 0000000..f696031 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/SwitchConstSupport.java @@ -0,0 +1,222 @@ +package click.kamil.springstatemachineexporter.analysis.pipeline; + +import org.eclipse.jdt.core.dom.ASTNode; +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.ReturnStatement; +import org.eclipse.jdt.core.dom.StringLiteral; +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.ThrowStatement; +import org.eclipse.jdt.core.dom.YieldStatement; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * Shared walk of switch statement / expression arms (arrow, yield, return, default blocks). + * Used by event-identity const maps, {@code ConstantExtractor}, and switch poly collection. + */ +public final class SwitchConstSupport { + + private SwitchConstSupport() { + } + + public static List statementsOf(ASTNode switchNode) { + if (switchNode instanceof SwitchExpression se) { + return se.statements(); + } + if (switchNode instanceof SwitchStatement ss) { + return ss.statements(); + } + return List.of(); + } + + /** + * Payload expressions for every arm, including {@code default} block yields. + * Opaque / empty arms (throw-only, empty blocks) are omitted — use + * {@link #hasOpaqueArm(List)} for fail-closed unique-agreement checks. + */ + public static List armPayloadExpressions(List statements) { + List payloads = new ArrayList<>(); + if (statements == null) { + return payloads; + } + boolean inArm = false; + for (Object stmtObj : statements) { + if (stmtObj instanceof SwitchCase) { + inArm = true; + continue; + } + if (!inArm) { + continue; + } + Expression armExpr = directArmExpression(stmtObj); + if (armExpr != null) { + payloads.add(armExpr); + inArm = false; + continue; + } + Expression fromBlock = blockArmExpression(stmtObj); + if (fromBlock != null) { + payloads.add(fromBlock); + inArm = false; + continue; + } + if (isOpaqueArmStatement(stmtObj)) { + inArm = false; + } + } + return payloads; + } + + public static List armPayloadExpressions(ASTNode switchNode) { + return armPayloadExpressions(statementsOf(switchNode)); + } + + /** + * True when any switch arm has no resolvable payload (throw-only, empty block, etc.). + * Callers that need fail-closed unique agreement must not treat partial payloads as unique. + */ + public static boolean hasOpaqueArm(List statements) { + if (statements == null || statements.isEmpty()) { + return false; + } + boolean inArm = false; + boolean sawPayload = false; + for (Object stmtObj : statements) { + if (stmtObj instanceof SwitchCase) { + if (inArm && !sawPayload) { + return true; + } + inArm = true; + sawPayload = false; + continue; + } + if (!inArm) { + continue; + } + if (directArmExpression(stmtObj) != null || blockArmExpression(stmtObj) != null) { + sawPayload = true; + inArm = false; + continue; + } + if (isOpaqueArmStatement(stmtObj)) { + return true; + } + } + return inArm && !sawPayload; + } + + public static boolean hasOpaqueArm(ASTNode switchNode) { + return hasOpaqueArm(statementsOf(switchNode)); + } + + /** + * Non-default string/resolved case labels → arm payload expression. + * Default arms are skipped (lookup maps must not invent a key for default). + */ + public static Map stringCasePayloads( + List statements, + Function caseKeyResolver) { + Map map = new LinkedHashMap<>(); + if (statements == null || caseKeyResolver == null) { + return map; + } + String currentKey = null; + for (Object stmtObj : statements) { + if (stmtObj instanceof SwitchCase sc) { + if (sc.isDefault()) { + currentKey = null; + continue; + } + currentKey = null; + for (Object exprObj : sc.expressions()) { + if (exprObj instanceof StringLiteral sl) { + currentKey = sl.getLiteralValue(); + } else if (exprObj instanceof Expression ex) { + String resolved = caseKeyResolver.apply(ex); + if (resolved != null && !resolved.isBlank()) { + currentKey = resolved; + } + } + } + continue; + } + if (currentKey == null) { + continue; + } + Expression armExpr = directArmExpression(stmtObj); + if (armExpr == null) { + armExpr = blockArmExpression(stmtObj); + } + if (armExpr != null) { + map.put(currentKey, armExpr); + currentKey = null; + } + } + return map; + } + + public static Map stringCasePayloads( + ASTNode switchNode, + Function caseKeyResolver) { + return stringCasePayloads(statementsOf(switchNode), caseKeyResolver); + } + + private static boolean isOpaqueArmStatement(Object stmtObj) { + if (stmtObj instanceof ThrowStatement) { + return true; + } + if (stmtObj instanceof Block block) { + if (block.statements().isEmpty()) { + return true; + } + for (Object nested : block.statements()) { + if (nested instanceof YieldStatement || nested instanceof ReturnStatement) { + return false; + } + } + for (Object nested : block.statements()) { + if (nested instanceof ThrowStatement) { + return true; + } + } + return true; + } + return false; + } + + private static Expression directArmExpression(Object stmtObj) { + if (stmtObj instanceof ReturnStatement rs) { + return rs.getExpression(); + } + if (stmtObj instanceof YieldStatement ys) { + return ys.getExpression(); + } + if (stmtObj instanceof ExpressionStatement es) { + return es.getExpression(); + } + return null; + } + + private static Expression blockArmExpression(Object stmtObj) { + if (!(stmtObj instanceof Block block)) { + return null; + } + for (Object nested : block.statements()) { + if (nested instanceof YieldStatement ys) { + return ys.getExpression(); + } + if (nested instanceof ReturnStatement rs) { + return rs.getExpression(); + } + } + return null; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java index 42397dd..50e5584 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java @@ -219,6 +219,9 @@ public final class BooleanConstraintEvaluator { if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) { return null; } + if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isAnalysisToken(boundValue)) { + return null; + } if (isIncompleteDottedName(boundValue)) { return null; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java index 5c52ddc..fc1c150 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java @@ -61,6 +61,55 @@ public class ConstantResolver { return evaluateSwitchStatement(ss, paramValues, context, new HashSet<>()); } + /** Unified switch eval for expression or statement nodes. */ + public String evaluateSwitch(ASTNode switchNode, java.util.Map paramValues, CodebaseContext context) { + if (switchNode instanceof org.eclipse.jdt.core.dom.SwitchExpression se) { + return evaluateSwitchWithParams(se, paramValues, context); + } + if (switchNode instanceof org.eclipse.jdt.core.dom.SwitchStatement ss) { + return evaluateSwitchWithParams(ss, paramValues, context); + } + return null; + } + + /** + * Resolves the first return expression of {@code md} under {@code callBindings} + * (switch returns go through {@link #evaluateSwitch}). + */ + public String evaluateMethodReturn( + MethodDeclaration md, java.util.Map callBindings, CodebaseContext context) { + if (md == null || md.getBody() == null) { + return null; + } + final Expression[] returnExpr = new Expression[1]; + md.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(ReturnStatement node) { + if (returnExpr[0] == null && node.getExpression() != null) { + returnExpr[0] = node.getExpression(); + } + return false; + } + }); + if (returnExpr[0] == null) { + return null; + } + Expression ret = returnExpr[0]; + while (ret instanceof ParenthesizedExpression pe) { + ret = pe.getExpression(); + } + java.util.Map params = callBindings != null ? callBindings : java.util.Map.of(); + if (ret instanceof org.eclipse.jdt.core.dom.SwitchExpression se) { + return evaluateSwitchWithParams(se, params, context); + } + context.setAnalysisNamedBindings(params); + try { + return resolve(ret, context); + } finally { + context.clearAnalysisNamedBindings(); + } + } + private String resolveInternal(Expression expr, CodebaseContext context, Set visited) { if (expr == null) return null; @@ -71,6 +120,9 @@ public class ConstantResolver { if (expr instanceof ParenthesizedExpression pe) { return resolveInternal(pe.getExpression(), context, visited); } + if (expr instanceof org.eclipse.jdt.core.dom.SwitchExpression se) { + return evaluateSwitchExpression(se, java.util.Map.of(), context, visited); + } if (expr instanceof ArrayAccess access) { String indexed = LiteralExpressionSupport.resolveArrayAccess( access, context, visited, (e, v) -> resolveInternal(e, context, v)); @@ -465,41 +517,26 @@ public class ConstantResolver { } private String evaluateSwitchStatement(org.eclipse.jdt.core.dom.SwitchStatement ss, java.util.Map paramValues, CodebaseContext context, Set visited) { - String switchVar = resolveExpressionWithParams(ss.getExpression(), paramValues, context, visited); + String switchVar = concreteSwitchSelector( + resolveExpressionWithParams(ss.getExpression(), paramValues, context, visited), + ss.getExpression()); if (switchVar == null) { + if (click.kamil.springstatemachineexporter.analysis.pipeline.SwitchConstSupport.hasOpaqueArm(ss)) { + return null; + } Set values = new LinkedHashSet<>(); - for (Object stmtObj : ss.statements()) { - if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs && rs.getExpression() != null) { - String val = resolveInternal(rs.getExpression(), context, visited); - if (val != null) { - if (val.startsWith("ENUM_SET:")) { - values.addAll(Arrays.asList(val.substring(9).split(","))); - } else { - values.add(val); - } - } - } else if (stmtObj instanceof org.eclipse.jdt.core.dom.YieldStatement ys && ys.getExpression() != null) { - String val = resolveInternal(ys.getExpression(), context, visited); - if (val != null) { - if (val.startsWith("ENUM_SET:")) { - values.addAll(Arrays.asList(val.substring(9).split(","))); - } else { - values.add(val); - } - } - } else if (stmtObj instanceof org.eclipse.jdt.core.dom.ExpressionStatement es && es.getExpression() != null) { - String val = resolveInternal(es.getExpression(), context, visited); - if (val != null) { - if (val.startsWith("ENUM_SET:")) { - values.addAll(Arrays.asList(val.substring(9).split(","))); - } else { - values.add(val); - } + for (Expression armExpr : click.kamil.springstatemachineexporter.analysis.pipeline.SwitchConstSupport.armPayloadExpressions(ss)) { + String val = resolveInternal(armExpr, context, visited); + if (val != null) { + if (val.startsWith("ENUM_SET:")) { + values.addAll(Arrays.asList(val.substring(9).split(","))); + } else { + values.add(val); } } } if (!values.isEmpty()) { - return "ENUM_SET:" + String.join(",", values); + return click.kamil.springstatemachineexporter.ast.common.AstUtils.formatEnumSet(values); } return null; } @@ -510,11 +547,12 @@ public class ConstantResolver { } boolean matchingCase = false; + boolean matchedNonDefault = false; for (Object stmtObj : ss.statements()) { if (stmtObj instanceof org.eclipse.jdt.core.dom.SwitchCase sc) { matchingCase = false; if (sc.isDefault()) { - matchingCase = true; + matchingCase = !matchedNonDefault; } else { for (Object exprObj : sc.expressions()) { String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context, visited); @@ -529,10 +567,12 @@ public class ConstantResolver { if (switchVar.contains(".") && caseVal.contains(".")) { if (switchVar.equals(caseVal)) { matchingCase = true; + matchedNonDefault = true; break; } } else if (switchVar.endsWith(caseVal)) { matchingCase = true; + matchedNonDefault = true; break; } } @@ -552,41 +592,27 @@ public class ConstantResolver { } private String evaluateSwitchExpression(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map paramValues, CodebaseContext context, Set visited) { - String switchVar = resolveExpressionWithParams(se.getExpression(), paramValues, context, visited); + String switchVar = concreteSwitchSelector( + resolveExpressionWithParams(se.getExpression(), paramValues, context, visited), + se.getExpression()); if (switchVar == null) { + if (click.kamil.springstatemachineexporter.analysis.pipeline.SwitchConstSupport.hasOpaqueArm(se)) { + // Throw-only / missing arms → fail-closed (do not publish partial last-arm sets). + return null; + } Set values = new LinkedHashSet<>(); - for (Object stmtObj : se.statements()) { - if (stmtObj instanceof org.eclipse.jdt.core.dom.YieldStatement ys && ys.getExpression() != null) { - String val = resolveInternal(ys.getExpression(), context, visited); - if (val != null) { - if (val.startsWith("ENUM_SET:")) { - values.addAll(Arrays.asList(val.substring(9).split(","))); - } else { - values.add(val); - } - } - } else if (stmtObj instanceof org.eclipse.jdt.core.dom.ExpressionStatement es && es.getExpression() != null) { - String val = resolveInternal(es.getExpression(), context, visited); - if (val != null) { - if (val.startsWith("ENUM_SET:")) { - values.addAll(Arrays.asList(val.substring(9).split(","))); - } else { - values.add(val); - } - } - } else if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs && rs.getExpression() != null) { - String val = resolveInternal(rs.getExpression(), context, visited); - if (val != null) { - if (val.startsWith("ENUM_SET:")) { - values.addAll(Arrays.asList(val.substring(9).split(","))); - } else { - values.add(val); - } + for (Expression armExpr : click.kamil.springstatemachineexporter.analysis.pipeline.SwitchConstSupport.armPayloadExpressions(se)) { + String val = resolveInternal(armExpr, context, visited); + if (val != null) { + if (val.startsWith("ENUM_SET:")) { + values.addAll(Arrays.asList(val.substring(9).split(","))); + } else { + values.add(val); } } } if (!values.isEmpty()) { - return "ENUM_SET:" + String.join(",", values); + return click.kamil.springstatemachineexporter.ast.common.AstUtils.formatEnumSet(values); } return null; } @@ -597,15 +623,15 @@ public class ConstantResolver { } boolean matchingCase = false; + boolean matchedNonDefault = false; for (Object stmtObj : se.statements()) { if (stmtObj instanceof org.eclipse.jdt.core.dom.SwitchCase sc) { matchingCase = false; if (sc.isDefault()) { - matchingCase = true; + matchingCase = !matchedNonDefault; } else { for (Object exprObj : sc.expressions()) { String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context, visited); - // Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) { caseVal = caseSn.getIdentifier(); if (switchType != null) { @@ -616,28 +642,61 @@ public class ConstantResolver { if (switchVar.contains(".") && caseVal.contains(".")) { if (switchVar.equals(caseVal)) { matchingCase = true; + matchedNonDefault = true; break; } } else if (switchVar.endsWith(caseVal)) { matchingCase = true; + matchedNonDefault = true; break; } } } } } else if (matchingCase) { + Expression armExpr = null; if (stmtObj instanceof org.eclipse.jdt.core.dom.ExpressionStatement es) { - return resolveInternal(es.getExpression(), context, visited); + armExpr = es.getExpression(); } else if (stmtObj instanceof org.eclipse.jdt.core.dom.YieldStatement ys) { - return resolveInternal(ys.getExpression(), context, visited); + armExpr = ys.getExpression(); } else if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) { - return resolveInternal(rs.getExpression(), context, visited); + armExpr = rs.getExpression(); + } else if (stmtObj instanceof org.eclipse.jdt.core.dom.Block block) { + for (Object nested : block.statements()) { + if (nested instanceof org.eclipse.jdt.core.dom.YieldStatement ys) { + armExpr = ys.getExpression(); + break; + } else if (nested instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) { + armExpr = rs.getExpression(); + break; + } + } + } + if (armExpr != null) { + return resolveInternal(armExpr, context, visited); } } } return null; } + /** + * Bound switch selectors only — identity names, blanks, and analysis tokens are unbound. + * Shared with dataflow so CR and DFM stay aligned. + */ + public static String concreteSwitchSelector(String resolved, Expression selectorExpr) { + if (resolved == null || resolved.isBlank() || selectorExpr == null) { + return null; + } + if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isAnalysisToken(resolved)) { + return null; + } + if (resolved.equals(selectorExpr.toString())) { + return null; + } + return resolved; + } + private String resolveExpressionWithParams( Expression expr, java.util.Map paramValues, CodebaseContext context, Set visited) { if (expr instanceof SimpleName sn) { 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 2020cac..b0561b2 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 @@ -6,6 +6,7 @@ import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccesso import click.kamil.springstatemachineexporter.analysis.flow.eventid.EventIdentityResolver; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver; +import click.kamil.springstatemachineexporter.analysis.pipeline.EnumExpansionHintSupport; import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder; import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry; import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper; @@ -442,13 +443,18 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String argValue = edge.getArguments().get(paramIndex); if (!FunctionalInterfaceTypes.isProvablyResolvedCallSiteArgument(argValue, expectedType)) { String actualType = null; - if (argValue.contains(".") && !argValue.contains("(")) { + // ENUM_SET tokens contain dots but are not type FQNs — treating them + // as types skips the edge and leaves the bare param (→ SYMBOLIC poly). + if (argValue != null + && !argValue.startsWith("ENUM_SET:") + && argValue.contains(".") + && !argValue.contains("(")) { String prefix = argValue.substring(0, argValue.lastIndexOf('.')); if (!prefix.equals("this") && !prefix.equals("super")) { actualType = prefix; } } - if (actualType == null && !argValue.contains(".")) { + if (actualType == null && argValue != null && !argValue.contains(".")) { actualType = variableTracer.getVariableDeclaredType(caller, argValue); } if (actualType != null && !typeResolver.isTypeCompatible(actualType, expectedType)) { @@ -1553,6 +1559,34 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return null; } + /** + * When the resolved receiver type is a scanned FQN but {@code calledMethod} was qualified with the + * caller's package (same simple name), re-home the target onto the receiver type. + */ + protected String qualifyCalledMethodWithReceiverType(String calledMethod, String receiverTypeFqn) { + if (calledMethod == null || receiverTypeFqn == null + || !calledMethod.contains(".") || !receiverTypeFqn.contains(".")) { + return calledMethod; + } + if (context.getTypeDeclaration(receiverTypeFqn) == null) { + return calledMethod; + } + int methodDot = calledMethod.lastIndexOf('.'); + String methodName = calledMethod.substring(methodDot + 1); + String calledClass = calledMethod.substring(0, methodDot); + if (calledClass.equals(receiverTypeFqn)) { + return calledMethod; + } + String calledSimple = calledClass.contains(".") + ? calledClass.substring(calledClass.lastIndexOf('.') + 1) + : calledClass; + String receiverSimple = receiverTypeFqn.substring(receiverTypeFqn.lastIndexOf('.') + 1); + if (calledSimple.equals(receiverSimple)) { + return receiverTypeFqn + "." + methodName; + } + return calledMethod; + } + /** * For CHA-expanded targets {@code Impl.method}, prefer the concrete impl class as the edge's * receiver type so pathfinding can prune sibling overrides inside that collaborator hierarchy. @@ -2898,11 +2932,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { declaredReceiverType = injectedReceiverType; } for (String calledMethod : calledMethods) { + String qualifiedTarget = qualifyCalledMethodWithReceiverType( + calledMethod, declaredReceiverType); String receiverTypeFqn = - refineReceiverTypeForTarget(declaredReceiverType, calledMethod); + refineReceiverTypeForTarget(declaredReceiverType, qualifiedTarget); CallEdge edge = new CallEdge( - calledMethod, args, receiver, null, receiverTypeFqn); - edge.setConstraint(resolveConstraint(node, calledMethod, constraint)); + qualifiedTarget, args, receiver, null, receiverTypeFqn); + edge.setConstraint(resolveConstraint(node, qualifiedTarget, constraint)); graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge); } for (Object argObj : node.arguments()) { @@ -3742,11 +3778,31 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { List path, Map> callGraph, TriggerPoint tp) { - if (polymorphicEvents == null || polymorphicEvents.isEmpty()) { + if (polymorphicEvents == null) { + return polymorphicEvents; + } + // Empty poly: expand only via name()/toUpperCase caller hints (PolymorphicDispatch). + if (polymorphicEvents.isEmpty()) { + if (callerArgumentHasEnumExpansionHint(path)) { + List fromCaller = resolvePolymorphicEventsFromCallerArgument(path); + if (fromCaller.stream().anyMatch(this::looksLikeEnumConstant)) { + return fromCaller; + } + } return polymorphicEvents; } Map bindings = resolvePathBindings(path, callGraph, Map.of()); if (bindings.isEmpty()) { + boolean symbolicOnly = polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant); + if (symbolicOnly) { + List hinted = resolvePolymorphicEventsFromCallerArgument(path); + // Only adopt when caller-arg path set an expansion hint (toUpperCase/name), + // detected as concrete enum members without relying on multi-class widen alone. + if (hinted.stream().anyMatch(this::looksLikeEnumConstant) + && callerArgumentHasEnumExpansionHint(path)) { + return hinted; + } + } if (hasMultiClassPolymorphicPath(path) && polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) { List callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path); @@ -3876,7 +3932,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (candidateExpr == null) { continue; } - if (candidateExpr.toString().contains(".toUpperCase()")) { + if (EnumExpansionHintSupport.expressionHasHint(candidateExpr)) { expansionHint[0] = true; } if (candidateExpr instanceof MethodInvocation localMethod @@ -3896,6 +3952,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } } } + if (resolvedPathMethod == null + && context.findMethodDeclaration( + callerType, localMethod.getName().getIdentifier(), true) != null) { + resolvedPathMethod = callerClass + "." + localMethod.getName().getIdentifier(); + } if (resolvedPathMethod != null && resolvedPathMethod.contains(".")) { String ownerFqn = resolvedPathMethod.substring(0, resolvedPathMethod.lastIndexOf('.')); String methodName = resolvedPathMethod.substring(resolvedPathMethod.lastIndexOf('.') + 1); @@ -3903,7 +3964,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { MethodDeclaration pathMethodDeclaration = pathOwnerType != null ? context.findMethodDeclaration(pathOwnerType, methodName, true) : null; - if (hasEnumExpansionHint(pathMethodDeclaration)) { + if (EnumExpansionHintSupport.methodBodyHasHint(pathMethodDeclaration)) { expansionHint[0] = true; String hintedEnumType = inferEnumTypeFromMethod(pathMethodDeclaration); if (hintedEnumType != null) { @@ -3952,9 +4013,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if ((expansionHint[0] || hasMultiClassPolymorphicPath(path)) && distinct.stream().noneMatch(AbstractCallGraphEngine.this::looksLikeEnumConstant)) { List expanded = expandSingleSymbolicType(distinct); - if (expanded.isEmpty() && hasMultiClassPolymorphicPath(path)) { + if (expanded.isEmpty() && (expansionHint[0] || hasMultiClassPolymorphicPath(path))) { expanded = inferEnumValuesFromPath(path); } + if (expanded.isEmpty() && expansionHint[0]) { + String enumType = inferEnumTypeFromMethod(callerDeclaration); + if (enumType != null) { + expanded = expandDeclaredEnumValues(enumType); + } + } if (!expanded.isEmpty()) { return expanded; } @@ -3962,6 +4029,85 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return distinct; } + /** + * True when the sendEvent (or trigger) argument at the caller shows a dynamic string + * transform ({@code toUpperCase}/{@code name}) that should widen SYMBOLIC to enum members. + */ + private boolean callerArgumentHasEnumExpansionHint(List path) { + if (path == null || path.size() < 2) { + return false; + } + 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 false; + } + MethodDeclaration callerDeclaration = context.findMethodDeclaration(callerType, callerMethodName, true); + if (callerDeclaration == null || callerDeclaration.getBody() == null) { + return false; + } + final boolean[] hint = {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 toCheck = new ArrayList<>(); + if (argument instanceof SimpleName && variableTracer != null) { + toCheck.addAll(variableTracer.traceVariableAll(argument)); + } + if (toCheck.isEmpty() && argument != null) { + toCheck.add(argument); + } + for (Expression argumentExpr : toCheck) { + if (argumentExpr != null && EnumExpansionHintSupport.expressionHasHint(argumentExpr)) { + hint[0] = true; + return false; + } + if (argumentExpr instanceof MethodInvocation localMethod + && (localMethod.getExpression() == null + || localMethod.getExpression() instanceof ThisExpression)) { + String owner = findPathMethodByName(path, localMethod.getName().getIdentifier()); + if (owner == null) { + String fallbackOwner = findPrecedingConcretePathClass(path, callerClass); + if (fallbackOwner != null) { + owner = fallbackOwner + "." + localMethod.getName().getIdentifier(); + } + } + if (owner == null) { + // Same-class private helper not always on the call path FQN list + MethodDeclaration localMd = context.findMethodDeclaration( + callerType, localMethod.getName().getIdentifier(), true); + if (EnumExpansionHintSupport.methodBodyHasHint(localMd)) { + hint[0] = true; + return false; + } + } + if (owner != null && owner.contains(".")) { + String ownerFqn = owner.substring(0, owner.lastIndexOf('.')); + String methodName = owner.substring(owner.lastIndexOf('.') + 1); + TypeDeclaration td = context.getTypeDeclaration(ownerFqn); + MethodDeclaration md = td != null + ? context.findMethodDeclaration(td, methodName, true) : null; + if (EnumExpansionHintSupport.methodBodyHasHint(md)) { + hint[0] = true; + return false; + } + } + } + } + return super.visit(node); + } + }); + return hint[0]; + } + private List inferEnumValuesFromPath(List path) { if (path == null || path.isEmpty()) { return List.of(); @@ -4049,25 +4195,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { 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; 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 dead27d..c25650f 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 @@ -6,7 +6,9 @@ import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver import click.kamil.springstatemachineexporter.analysis.pipeline.GetterBodyConstantScanner; import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry; import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget; +import click.kamil.springstatemachineexporter.analysis.pipeline.SwitchConstSupport; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.ast.common.AstUtils; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import lombok.extern.slf4j.Slf4j; import org.eclipse.jdt.core.dom.*; @@ -214,11 +216,15 @@ public class ConstantExtractor { if (expr == null || resolved == null) { return false; } + // Always walk switch arms — a single concrete from CR (e.g. default-only) must not + // short-circuit poly collection via SwitchConstSupport. + if (expr instanceof SwitchExpression) { + return true; + } if (!(resolved.startsWith(""))) { return false; } - return expr instanceof SwitchExpression - || expr instanceof ConditionalExpression; + return expr instanceof ConditionalExpression; } public void extractConstantsFromArgument(Expression argObj, List constants) { @@ -418,6 +424,25 @@ public class ConstantExtractor { } } } + } else if (retExpr instanceof SwitchExpression switchExpression) { + // Prefer SwitchConstSupport arms — DFM may collapse unbound switches to + // a single default/last arm and hide poly. Opaque (throw) arms → fail-closed. + if (SwitchConstSupport.hasOpaqueArm(switchExpression)) { + handled = true; + } else { + extractConstantsFromSwitchLike(switchExpression, constants); + String evaluated = constantResolver.evaluateSwitch( + switchExpression, Map.of(), context); + if (evaluated != null && evaluated.startsWith("ENUM_SET:")) { + for (String eVal : AstUtils.parseEnumSet(evaluated)) { + String parsed = parseEnumSetElement(eVal); + if (!constants.contains(parsed)) { + constants.add(parsed); + } + } + } + handled = !constants.isEmpty(); + } } if (!handled && variableTracer != null && constructorAnalyzer != null) { List tracedRetExprs = variableTracer.traceVariableAll(retExpr); @@ -569,25 +594,12 @@ public class ConstantExtractor { } 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); - } - }); + if (SwitchConstSupport.hasOpaqueArm(switchNode)) { + return; + } + for (Expression armExpr : SwitchConstSupport.armPayloadExpressions(switchNode)) { + extractConstantsFromExpression(armExpr, constants); + } } private boolean isKeyedLookupMethod(MethodInvocation mi) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java index c891e7d..524bbab 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java @@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.analysis.service; import click.kamil.springstatemachineexporter.analysis.model.LibraryHint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.pipeline.MessageCarrierSupport; import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; @@ -879,6 +880,22 @@ public class GenericEventDetector { } private String extractEventFromMessageBuilder(Expression expr) { + if (expr == null) { + return null; + } + Expression sharedPeel = MessageCarrierSupport.peelEventFromMessageExpr(expr); + if (sharedPeel != null && sharedPeel != expr) { + String fromShared = extractEventFromMessageBuilder(sharedPeel); + if (fromShared != null) { + return fromShared; + } + String resolved = constantResolver.resolve(sharedPeel, context); + if (resolved != null) { + return resolved; + } + return sharedPeel.toString(); + } + if (expr instanceof CastExpression ce) { return extractEventFromMessageBuilder(ce.getExpression()); } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java index 8084f98..5dc5c5b 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java @@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.TruncatedEnumRefReconstructor; +import click.kamil.springstatemachineexporter.ast.common.AstUtils; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import java.util.HashMap; @@ -216,6 +217,9 @@ public class PathBindingEvaluator { if (resolved == null || resolved.isBlank() || resolved.equals(paramName)) { return false; } + if (AstUtils.isAnalysisToken(resolved) || !AstUtils.isConcreteConstant(resolved)) { + return false; + } if (BooleanConstraintEvaluator.isIncompleteDottedName(resolved)) { return false; } @@ -224,7 +228,7 @@ public class PathBindingEvaluator { if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(candidate)) { return false; } - if (candidate.contains("(") && !isEnumLikeConstant(candidate)) { + if (candidate.contains("(") && !AstUtils.isEnumLikeConstantName(candidate)) { return false; } return true; @@ -246,19 +250,33 @@ public class PathBindingEvaluator { if (rawValue == null || rawValue.isBlank()) { return null; } - if (rawValue.startsWith("\"") || isEnumLikeConstant(rawValue)) { + if (AstUtils.isAnalysisToken(rawValue)) { + return null; + } + if (rawValue.startsWith("\"") || AstUtils.isEnumLikeConstantName(rawValue)) { return maybeReconstruct(normalizeLiteral(rawValue)); } if (bindings.containsKey(rawValue)) { - return maybeReconstruct(bindings.get(rawValue)); + String bound = bindings.get(rawValue); + // Analysis tokens are not concrete proofs — fall through to re-resolve under path bindings. + if (bound != null && !AstUtils.isAnalysisToken(bound)) { + return maybeReconstruct(bound); + } } String traced = variableTracer.traceLocalVariable(callerFqn, rawValue, bindings); if (traced != null && !traced.isBlank()) { - if (traced.startsWith("\"") || isEnumLikeConstant(traced)) { + if (AstUtils.isAnalysisToken(traced)) { + String fromCall = expressionResolver.resolveMethodCallFromSource(callerFqn, rawValue, bindings); + if (fromCall != null && !AstUtils.isAnalysisToken(fromCall)) { + return maybeReconstruct(fromCall); + } + return null; + } + if (traced.startsWith("\"") || AstUtils.isEnumLikeConstantName(traced)) { return maybeReconstruct(traced); } String fromCall = expressionResolver.resolveMethodCallFromSource(callerFqn, rawValue, bindings); - if (fromCall != null) { + if (fromCall != null && !AstUtils.isAnalysisToken(fromCall)) { return maybeReconstruct(fromCall); } if (!traced.equals(rawValue)) { @@ -282,17 +300,6 @@ public class PathBindingEvaluator { return value; } - private static boolean isEnumLikeConstant(String value) { - if (value == null || value.isEmpty()) { - return false; - } - int dot = value.lastIndexOf('.'); - if (dot <= 0 || dot >= value.length() - 1) { - return false; - } - return Character.isUpperCase(value.charAt(dot + 1)); - } - private static CallEdge findEdge( String caller, String target, Map> callGraph, CallGraphPathFinder pathFinder) { List edges = callGraph.get(caller); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolver.java index e92c328..635c8e1 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolver.java @@ -21,14 +21,24 @@ public class PathBindingExpressionResolver { private final CodebaseContext context; private final VariableTracer variableTracer; private final ConstantResolver constantResolver; + private final TypeResolver typeResolver; public PathBindingExpressionResolver( CodebaseContext context, VariableTracer variableTracer, ConstantResolver constantResolver) { + this(context, variableTracer, constantResolver, new TypeResolver(context)); + } + + PathBindingExpressionResolver( + CodebaseContext context, + VariableTracer variableTracer, + ConstantResolver constantResolver, + TypeResolver typeResolver) { this.context = context; this.variableTracer = variableTracer; this.constantResolver = constantResolver; + this.typeResolver = typeResolver; } public String resolveMethodCallFromSource(String callerFqn, String varName, Map bindings) { @@ -111,7 +121,11 @@ public class PathBindingExpressionResolver { context.setAnalysisNamedBindings(callBindings); try { - return resolveViaDataflow(mi); + String viaDataflow = resolveViaDataflow(mi); + if (viaDataflow != null && !viaDataflow.isBlank() && !AstUtils.isAnalysisToken(viaDataflow)) { + return viaDataflow; + } + return constantResolver.evaluateMethodReturn(md, callBindings, context); } finally { context.setAnalysisNamedBindings(bindings); } @@ -139,10 +153,7 @@ public class PathBindingExpressionResolver { if (value == null) { return null; } - if (value.startsWith("ENUM_SET:") - || value.startsWith("")) { + if (AstUtils.isAnalysisToken(value)) { return value; } if (value.startsWith("\"") || AstUtils.isEnumLikeConstantName(value)) { @@ -179,27 +190,28 @@ public class PathBindingExpressionResolver { if (td == null) { return null; } + CompilationUnit cu = td.getRoot() instanceof CompilationUnit c ? c : null; for (FieldDeclaration field : td.getFields()) { for (Object fragmentObj : field.fragments()) { VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj; if (fragment.getName().getIdentifier().equals(fieldName)) { IVariableBinding binding = fragment.resolveBinding(); - if (binding != null && binding.getType() != null) { - return binding.getType().getErasure().getQualifiedName(); + if (binding != null && binding.getType() != null && !binding.getType().isRecovered()) { + String fqn = binding.getType().getErasure().getQualifiedName(); + if (fqn != null && fqn.contains(".") && !fqn.startsWith("<")) { + return fqn; + } } - String simpleType = field.getType().toString(); - TypeDeclaration resolved = context.getTypeDeclaration(simpleType); + String viaTr = typeResolver.resolveTypeToFqn(field.getType(), cu); + if (viaTr != null && viaTr.contains(".")) { + return viaTr; + } + AbstractTypeDeclaration resolved = + context.getAbstractTypeDeclaration(field.getType().toString(), cu); if (resolved != null) { return context.getFqn(resolved); } - if (td.getRoot() instanceof CompilationUnit cu && cu.getPackage() != null) { - String pkg = cu.getPackage().getName().getFullyQualifiedName(); - resolved = context.getTypeDeclaration(pkg + "." + simpleType); - if (resolved != null) { - return context.getFqn(resolved); - } - } - return simpleType; + return field.getType().toString(); } } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java index 4860a44..480edcf 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java @@ -336,6 +336,21 @@ public class VariableTracer { List stringified = new ArrayList<>(); for (BranchAssignment assignment : filtered) { Expression expr = assignment.expression(); + // Under path bindings, resolve MI initializers via CR (mapper.fromString(commandKey)). + if (branchBindings != null && !branchBindings.isEmpty() + && expr instanceof MethodInvocation mi) { + String fromCall = resolveMethodInvocationUnderBindings(mi, methodFqn, branchBindings); + if (fromCall != null && !fromCall.isBlank() + && !AstUtils.isAnalysisToken(fromCall)) { + stringified.add(fromCall); + continue; + } + } + String multiConst = stringifyMultiConstantDefs(expr, branchBindings); + if (multiConst != null) { + stringified.add(multiConst); + continue; + } Expression traced = traceVariable(expr); if (traced instanceof MethodInvocation mi) { Expression innerMost = unwrapMethodInvocation(mi, 0, methodFqn); @@ -408,6 +423,126 @@ public class VariableTracer { return null; } + private String resolveMethodInvocationUnderBindings( + MethodInvocation mi, String callerFqn, Map branchBindings) { + String ownerFqn = resolveInvocationOwnerFqn(mi, callerFqn); + if (ownerFqn == null) { + return null; + } + TypeDeclaration owner = context.getTypeDeclaration(ownerFqn); + if (owner == null) { + return null; + } + MethodDeclaration md = context.findMethodDeclaration(owner, mi.getName().getIdentifier(), true); + if (md == null) { + return null; + } + Map callBindings = new HashMap<>(branchBindings != null ? branchBindings : Map.of()); + for (int i = 0; i < md.parameters().size() && i < mi.arguments().size(); i++) { + SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i); + Expression arg = (Expression) mi.arguments().get(i); + String argValue = null; + if (arg instanceof SimpleName sn) { + argValue = callBindings.getOrDefault(sn.getIdentifier(), sn.getIdentifier()); + } else if (arg instanceof StringLiteral sl) { + argValue = sl.getLiteralValue(); + } else { + argValue = constantResolver.resolve(arg, context); + } + if (argValue != null) { + callBindings.put(param.getName().getIdentifier(), argValue); + } + } + return constantResolver.evaluateMethodReturn(md, callBindings, context); + } + + private String resolveInvocationOwnerFqn(MethodInvocation mi, String callerFqn) { + IMethodBinding methodBinding = mi.resolveMethodBinding(); + if (methodBinding != null && methodBinding.getDeclaringClass() != null + && !methodBinding.getDeclaringClass().isRecovered()) { + String fqn = methodBinding.getDeclaringClass().getErasure().getQualifiedName(); + if (fqn != null && fqn.contains(".") && !fqn.startsWith("<")) { + return fqn; + } + } + Expression receiver = mi.getExpression(); + if (!(receiver instanceof SimpleName sn) || callerFqn == null || !callerFqn.contains(".")) { + return null; + } + String className = callerFqn.substring(0, callerFqn.lastIndexOf('.')); + TypeDeclaration td = context.getTypeDeclaration(className); + if (td == null) { + return null; + } + CompilationUnit cu = td.getRoot() instanceof CompilationUnit c ? c : null; + for (FieldDeclaration field : td.getFields()) { + for (Object fragmentObj : field.fragments()) { + VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj; + if (!fragment.getName().getIdentifier().equals(sn.getIdentifier())) { + continue; + } + IVariableBinding binding = fragment.resolveBinding(); + if (binding != null && binding.getType() != null && !binding.getType().isRecovered()) { + String fqn = binding.getType().getErasure().getQualifiedName(); + if (fqn != null && fqn.contains(".") && !fqn.startsWith("<")) { + return fqn; + } + } + if (typeResolver != null) { + String viaTr = typeResolver.resolveTypeToFqn(field.getType(), cu); + if (viaTr != null && viaTr.contains(".")) { + return viaTr; + } + } + AbstractTypeDeclaration resolved = + context.getAbstractTypeDeclaration(field.getType().toString(), cu); + if (resolved != null) { + return context.getFqn(resolved); + } + } + } + return null; + } + + private String stringifyMultiConstantDefs(Expression expr, Map branchBindings) { + if (expr instanceof SwitchExpression se) { + Map params = branchBindings != null ? branchBindings : Map.of(); + String evaluated = constantResolver.evaluateSwitchWithParams(se, params, context); + if (evaluated != null) { + return evaluated; + } + } + boolean pushedBindings = branchBindings != null && !branchBindings.isEmpty(); + if (pushedBindings) { + context.setAnalysisNamedBindings(branchBindings); + } + try { + List defs = traceVariableAll(expr); + if (defs.size() <= 1) { + return null; + } + LinkedHashSet consts = new LinkedHashSet<>(); + for (Expression def : defs) { + String val = constantResolver.resolve(def, context); + if (val == null || val.isBlank() || AstUtils.isAnalysisToken(val)) { + return null; + } + consts.add(val); + } + if (consts.isEmpty()) { + return null; + } + if (consts.size() == 1) { + return consts.iterator().next(); + } + return AstUtils.formatEnumSet(consts); + } finally { + if (pushedBindings) { + context.clearAnalysisNamedBindings(); + } + } + } + private String evaluateSwitchAssignment(String methodFqn, String varName, Map parameterValues) { TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.'))); if (td == null) { @@ -423,8 +558,8 @@ public class VariableTracer { @Override public boolean visit(VariableDeclarationFragment node) { if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { - Expression init = node.getInitializer(); - if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION || init.getNodeType() == ASTNode.SWITCH_STATEMENT) { + Expression init = peelExpression(node.getInitializer()); + if (init instanceof SwitchExpression) { switchNode[0] = init; return false; } @@ -435,8 +570,8 @@ public class VariableTracer { @Override public boolean visit(Assignment node) { if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) { - Expression rhs = node.getRightHandSide(); - if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION || rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) { + Expression rhs = peelExpression(node.getRightHandSide()); + if (rhs instanceof SwitchExpression) { switchNode[0] = rhs; return false; } @@ -447,9 +582,6 @@ public class VariableTracer { if (switchNode[0] instanceof SwitchExpression se) { return constantResolver.evaluateSwitchWithParams(se, parameterValues, context); } - if (switchNode[0] instanceof SwitchStatement ss) { - return constantResolver.evaluateSwitchWithParams(ss, parameterValues, context); - } return null; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/AstUtils.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/AstUtils.java index cf8fa3e..6c5515e 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/AstUtils.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/AstUtils.java @@ -24,6 +24,8 @@ import org.eclipse.jdt.core.dom.SimpleType; import org.eclipse.jdt.core.dom.Type; import org.eclipse.jdt.core.dom.TypeDeclaration; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Map; @@ -404,10 +406,62 @@ public final class AstUtils { return "\"" + boundValue.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; } - public static boolean isEnumLikeConstantName(String value) { + /** + * Analysis-only tokens that must never be treated as concrete enum/string proofs. + * Includes {@code ENUM_SET:…}, {@code }, and wildcard tails like {@code .*>}. + */ + public static boolean isAnalysisToken(String value) { if (value == null || value.isEmpty()) { return false; } + if (value.startsWith("ENUM_SET:") || value.startsWith(""); + } + + /** True when {@code value} is a concrete constant suitable for binding storage / constraint substitution. */ + public static boolean isConcreteConstant(String value) { + if (value == null || value.isBlank() || isAnalysisToken(value)) { + return false; + } + return true; + } + + public static String formatEnumSet(Collection values) { + if (values == null || values.isEmpty()) { + return null; + } + return "ENUM_SET:" + String.join(",", values); + } + + /** + * @return elements inside an {@code ENUM_SET:…} token, or empty if not an enum-set token + */ + public static List parseEnumSet(String value) { + if (value == null || !value.startsWith("ENUM_SET:")) { + return List.of(); + } + String body = value.substring("ENUM_SET:".length()); + if (body.isBlank()) { + return List.of(); + } + List out = new ArrayList<>(); + for (String part : body.split(",")) { + if (part != null && !part.isBlank()) { + out.add(part); + } + } + return out; + } + + public static boolean isEnumLikeConstantName(String value) { + if (value == null || value.isEmpty() || isAnalysisToken(value)) { + return false; + } int dot = value.lastIndexOf('.'); if (dot <= 0 || dot >= value.length() - 1) { return false; diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrowerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrowerTest.java index 1fc18b5..596dc85 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrowerTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrowerTest.java @@ -96,6 +96,44 @@ class InstanceofNarrowerTest { assertThat(refined.uniqueOrNull().typeFqn()).contains("PayRichEvent"); } + @Test + void guardedAndInstanceofProvesConcrete(@TempDir Path tempDir) throws Exception { + Fixture f = scan(tempDir, """ + package com.example; + class C { + void fire(Object o) { + if (o instanceof PayRichEvent p && p.getA() != null) { + use(p.getA()); + } + } + void use(String s) {} + } + class PayRichEvent { String getA() { return "PAY"; } } + """); + MethodInvocation getA = findInvocation(f.fire, "getA"); + ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context); + assertThat(facts.uniqueOrNull()).isNotNull(); + assertThat(facts.uniqueOrNull().typeFqn()).contains("PayRichEvent"); + } + + @Test + void castToConcreteRefinesWeakBase(@TempDir Path tempDir) throws Exception { + Fixture f = scan(tempDir, """ + package com.example; + class C { + void fire(Object o) { + use(((PayRichEvent) o).getA()); + } + void use(String s) {} + } + class PayRichEvent { String getA() { return "PAY"; } } + """); + MethodInvocation getA = findInvocation(f.fire, "getA"); + ReceiverFacts base = ReceiverFacts.of(new AllocationSite("java.lang.Object")); + ReceiverFacts refined = InstanceofNarrower.refine(base, getA.getExpression(), getA, f.context); + assertThat(refined.uniqueOrNull().typeFqn()).contains("PayRichEvent"); + } + @Test void refineConcreteConflictFailsClosed(@TempDir Path tempDir) throws Exception { Fixture f = scan(tempDir, """ diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/EnumExpansionHintSupportTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/EnumExpansionHintSupportTest.java new file mode 100644 index 0000000..6abedc7 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/EnumExpansionHintSupportTest.java @@ -0,0 +1,224 @@ +package click.kamil.springstatemachineexporter.analysis.pipeline; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.ReturnStatement; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class EnumExpansionHintSupportTest { + + @Test + void zeroArgNameQualifies(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + class C { + String map(OrderEvent e) { + return e.name(); + } + } + enum OrderEvent { PAY } + """); + assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isTrue(); + } + + @Test + void getNameDoesNotQualify(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + class C { + String map(Person p) { + return p.getName(); + } + } + class Person { String getName() { return "x"; } } + """); + assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isFalse(); + } + + @Test + void toUpperCaseZeroArgQualifies(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + class C { + String map(String action) { + return action.toUpperCase(); + } + } + """); + assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isTrue(); + } + + @Test + void toUpperCaseWithLocaleQualifies(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + import java.util.Locale; + class C { + String map(String action) { + return action.toUpperCase(Locale.ROOT); + } + } + """); + assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isTrue(); + } + + @Test + void trimThenToUpperCaseChainQualifies(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + class C { + String map(String action) { + return action.trim().toUpperCase(); + } + } + """); + assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isTrue(); + } + + @Test + void valueOfThenNameChainQualifies(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + class C { + String map(String event) { + return OrderEvent.valueOf(event).name(); + } + } + enum OrderEvent { PAY } + """); + assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isTrue(); + } + + @Test + void plainValueOfDoesNotQualify(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + class C { + OrderEvent map(String str) { + return OrderEvent.valueOf(str); + } + } + enum OrderEvent { PAY } + """); + assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isFalse(); + } + + @Test + void stringLiteralContainingNameSubstringDoesNotQualify(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + class C { + String map() { + return ".name()"; + } + } + """); + assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isFalse(); + } + + @Test + void methodBodyHasHintForNameAndToUpperCase(@TempDir Path tempDir) throws Exception { + MethodDeclaration nameMd = method(tempDir, "nameHelper", """ + package com.example; + class C { + String nameHelper(String event) { + return OrderEvent.valueOf(event).name(); + } + String upperHelper(String action) { + return action.toUpperCase(); + } + String plainHelper(String str) { + return str.trim(); + } + } + enum OrderEvent { PAY } + """); + MethodDeclaration upperMd = sibling(nameMd, "upperHelper"); + MethodDeclaration plainMd = sibling(nameMd, "plainHelper"); + assertThat(EnumExpansionHintSupport.methodBodyHasHint(nameMd)).isTrue(); + assertThat(EnumExpansionHintSupport.methodBodyHasHint(upperMd)).isTrue(); + assertThat(EnumExpansionHintSupport.methodBodyHasHint(plainMd)).isFalse(); + } + + @Test + void isQualifyingHintRejectsNameWithArgs(@TempDir Path tempDir) throws Exception { + MethodDeclaration md = method(tempDir, "map", """ + package com.example; + class C { + String map(Weird w) { + return w.name("x"); + } + } + class Weird { String name(String s) { return s; } } + """); + MethodInvocation[] found = {null}; + md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if ("name".equals(node.getName().getIdentifier())) { + found[0] = node; + return false; + } + return super.visit(node); + } + }); + assertThat(found[0]).isNotNull(); + assertThat(EnumExpansionHintSupport.isQualifyingHint(found[0])).isFalse(); + } + + private static Expression returnExpr(Path tempDir, String source) throws Exception { + MethodDeclaration md = method(tempDir, "map", source); + ReturnStatement rs = findReturn(md); + return rs.getExpression(); + } + + private static MethodDeclaration method(Path tempDir, String methodName, String source) throws Exception { + Files.writeString(tempDir.resolve("C.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + TypeDeclaration td = context.getTypeDeclaration("com.example.C"); + if (td == null) { + td = context.getTypeDeclaration("C"); + } + assertThat(td).isNotNull(); + MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); + assertThat(md).isNotNull(); + return md; + } + + private static MethodDeclaration sibling(MethodDeclaration md, String methodName) { + TypeDeclaration td = (TypeDeclaration) md.getParent(); + MethodDeclaration found = null; + for (Object bodyDecl : td.bodyDeclarations()) { + if (bodyDecl instanceof MethodDeclaration candidate + && methodName.equals(candidate.getName().getIdentifier())) { + found = candidate; + break; + } + } + assertThat(found).isNotNull(); + return found; + } + + private static ReturnStatement findReturn(MethodDeclaration md) { + ReturnStatement[] found = {null}; + md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(ReturnStatement node) { + found[0] = node; + return false; + } + }); + assertThat(found[0]).isNotNull(); + return found[0]; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupportTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupportTest.java index 80f86cd..a556935 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupportTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupportTest.java @@ -37,6 +37,28 @@ class MessageCarrierSupportTest { assertThat(peeled.toString()).contains("PAY"); } + @Test + void peelsGetPayloadOnBuilderChain(@TempDir Path tempDir) throws Exception { + Expression sendArg = sendArg(tempDir, """ + package com.example; + class C { + void fire() { + send(MessageBuilder.withPayload(OrderEvent.PAY).build().getPayload()); + } + void send(Object e) {} + } + enum OrderEvent { PAY } + class MessageBuilder { + static MessageBuilder withPayload(Object e) { return new MessageBuilder(); } + MessageBuilder build() { return this; } + Object getPayload() { return null; } + } + """); + Expression peeled = MessageCarrierSupport.peelEventFromMessageExpr(sendArg); + assertThat(peeled).isNotNull(); + assertThat(peeled.toString()).contains("PAY"); + } + @Test void prefersAllowlistedHeaderOverPayload(@TempDir Path tempDir) throws Exception { Expression sendArg = sendArg(tempDir, """ diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/SwitchConstSupportTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/SwitchConstSupportTest.java new file mode 100644 index 0000000..c36ab78 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/SwitchConstSupportTest.java @@ -0,0 +1,152 @@ +package click.kamil.springstatemachineexporter.analysis.pipeline; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.ReturnStatement; +import org.eclipse.jdt.core.dom.TypeDeclaration; +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 static org.assertj.core.api.Assertions.assertThat; + +class SwitchConstSupportTest { + + @Test + void collectsIntCaseArrowAndDefault(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("C.java"), """ + package com.example; + class C { + void m(int type) { + String e = (((switch(type) { + case 1 -> "PAY"; + default -> { yield "CANCEL"; } + }))); + } + } + """); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + TypeDeclaration td = context.getTypeDeclaration("com.example.C"); + MethodDeclaration md = context.findMethodDeclaration(td, "m", true); + org.eclipse.jdt.core.dom.SwitchExpression[] se = {null}; + md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(org.eclipse.jdt.core.dom.SwitchExpression node) { + se[0] = node; + return false; + } + }); + assertThat(se[0]).isNotNull(); + List kinds = new java.util.ArrayList<>(); + for (Object o : se[0].statements()) { + kinds.add(o.getClass().getSimpleName() + ":" + o.toString().replace('\n', ' ')); + } + List arms = SwitchConstSupport.armPayloadExpressions(se[0]); + assertThat(arms) + .as("statements=%s", kinds) + .hasSize(2); + } + + @Test + void collectsArrowAndDefaultBlockArms(@TempDir Path tempDir) throws Exception { + MethodDeclaration md = method(tempDir, """ + package com.example; + class C { + String map(int type) { + return switch (type) { + case 1 -> "PAY"; + default -> { yield "CANCEL"; } + }; + } + } + """); + ReturnStatement rs = findReturn(md); + assertThat(rs.getExpression()).isInstanceOf(org.eclipse.jdt.core.dom.SwitchExpression.class); + List arms = SwitchConstSupport.armPayloadExpressions( + (org.eclipse.jdt.core.dom.ASTNode) rs.getExpression()); + assertThat(arms).hasSize(2); + assertThat(arms.get(0).toString()).contains("PAY"); + assertThat(arms.get(1).toString()).contains("CANCEL"); + } + + @Test + void prefersYieldOverSideEffectInBlock(@TempDir Path tempDir) throws Exception { + MethodDeclaration md = method(tempDir, """ + package com.example; + class C { + String map(String q) { + return switch (q) { + case "a" -> { + System.out.println("Processing a"); + yield "A8"; + } + case "b" -> { yield "A133"; } + default -> "PAY"; + }; + } + } + """); + ReturnStatement rs = findReturn(md); + List arms = SwitchConstSupport.armPayloadExpressions( + (org.eclipse.jdt.core.dom.ASTNode) rs.getExpression()); + assertThat(arms).hasSize(3); + assertThat(arms.get(0).toString()).contains("A8"); + assertThat(arms.get(1).toString()).contains("A133"); + assertThat(arms.get(2).toString()).contains("PAY"); + } + + @Test + void stringCasePayloadsSkipsDefault(@TempDir Path tempDir) throws Exception { + MethodDeclaration md = method(tempDir, """ + package com.example; + class C { + String map(String code) { + return switch (code) { + case "PAY" -> "PAY"; + case "SHIP" -> "SHIP"; + default -> { yield "CANCEL"; } + }; + } + } + """); + ReturnStatement rs = findReturn(md); + Map map = SwitchConstSupport.stringCasePayloads( + (org.eclipse.jdt.core.dom.ASTNode) rs.getExpression(), + ex -> null); + assertThat(map).containsOnlyKeys("PAY", "SHIP"); + assertThat(map.get("PAY").toString()).contains("PAY"); + } + + private static MethodDeclaration method(Path tempDir, String source) throws Exception { + Files.writeString(tempDir.resolve("C.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + TypeDeclaration td = context.getTypeDeclaration("com.example.C"); + if (td == null) { + td = context.getTypeDeclaration("C"); + } + assertThat(td).isNotNull(); + MethodDeclaration md = context.findMethodDeclaration(td, "map", true); + assertThat(md).isNotNull(); + return md; + } + + private static ReturnStatement findReturn(MethodDeclaration md) { + ReturnStatement[] found = {null}; + md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(ReturnStatement node) { + found[0] = node; + return false; + } + }); + assertThat(found[0]).isNotNull(); + return found[0]; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java index a9fbe2a..c3d10db 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java @@ -9,6 +9,22 @@ import static org.assertj.core.api.Assertions.assertThat; class BooleanConstraintEvaluatorBindingsTest { + @Test + void shouldRejectEnumSetBindingAsConcreteForShipConstraint() { + // ENUM_SET:…ORDER_SHIP must not prove command == ORDER_SHIP via trailing .CONST + assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings( + "command == ORDER_SHIP", + Map.of("command", "ENUM_SET:DomainCommand.ORDER_PAY,DomainCommand.ORDER_SHIP"))) + .isTrue(); // unbound/token → constraint ignored (compatible), not uniquely SHIP + // When treated as concrete endsWith, ship would falsely match; token rejection means + // the variable stays unsubstituted — isCompatible returns true for unbound vars. + // Prove it is NOT a unique concrete SHIP proof by also accepting PAY constraint: + assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings( + "command == ORDER_PAY", + Map.of("command", "ENUM_SET:DomainCommand.ORDER_PAY,DomainCommand.ORDER_SHIP"))) + .isTrue(); + } + @Test void shouldAcceptMatchingEnumSwitchConstraint() { assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings( diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverSwitchPolicyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverSwitchPolicyTest.java new file mode 100644 index 0000000..e364505 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverSwitchPolicyTest.java @@ -0,0 +1,81 @@ +package click.kamil.springstatemachineexporter.analysis.resolver; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.ASTParser; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.ReturnStatement; +import org.eclipse.jdt.core.dom.SwitchExpression; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.AST; +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.JavaCore; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConstantResolverSwitchPolicyTest { + + @Test + void boundSelectorMustNotTakeDefaultWhenCaseMatched() { + String source = """ + package com.example; + class M { + Object map(String commandKey) { + return switch (commandKey) { + case "order.pay" -> DomainCommand.ORDER_PAY; + case "order.ship" -> DomainCommand.ORDER_SHIP; + default -> DomainCommand.ORDER_CANCEL; + }; + } + } + enum DomainCommand { ORDER_PAY, ORDER_SHIP, ORDER_CANCEL } + """; + SwitchExpression se = firstReturnedSwitch(source); + ConstantResolver cr = new ConstantResolver(); + CodebaseContext ctx = new CodebaseContext(); + String resolved = cr.evaluateSwitchWithParams(se, Map.of("commandKey", "order.pay"), ctx); + assertThat(resolved).isEqualTo("DomainCommand.ORDER_PAY"); + } + + @Test + void unboundIdentitySelectorYieldsEnumSetNotLastWins() { + String source = """ + package com.example; + class M { + Object map(String type) { + return switch (type) { + case "a" -> "PAY"; + case "b" -> "SHIP"; + default -> { yield "CANCEL"; } + }; + } + } + """; + SwitchExpression se = firstReturnedSwitch(source); + ConstantResolver cr = new ConstantResolver(); + String resolved = cr.evaluateSwitchWithParams(se, Map.of(), new CodebaseContext()); + assertThat(resolved).startsWith("ENUM_SET:"); + assertThat(resolved).contains("PAY", "SHIP", "CANCEL"); + } + + private static SwitchExpression firstReturnedSwitch(String source) { + ASTParser parser = ASTParser.newParser(AST.JLS17); + Map options = JavaCore.getOptions(); + JavaCore.setComplianceOptions(JavaCore.VERSION_17, options); + parser.setCompilerOptions(options); + parser.setKind(ASTParser.K_COMPILATION_UNIT); + parser.setSource(source.toCharArray()); + CompilationUnit cu = (CompilationUnit) parser.createAST(null); + TypeDeclaration td = (TypeDeclaration) cu.types().get(0); + MethodDeclaration md = td.getMethods()[0]; + ReturnStatement rs = (ReturnStatement) md.getBody().statements().get(0); + ASTNode expr = rs.getExpression(); + while (expr instanceof org.eclipse.jdt.core.dom.ParenthesizedExpression pe) { + expr = pe.getExpression(); + } + return (SwitchExpression) expr; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphReceiverTypeQualificationTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphReceiverTypeQualificationTest.java new file mode 100644 index 0000000..311bd98 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphReceiverTypeQualificationTest.java @@ -0,0 +1,59 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +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 static org.assertj.core.api.Assertions.assertThat; + +class CallGraphReceiverTypeQualificationTest { + + @Test + void targetMethodClassMatchesReceiverTypeFqnAcrossPackages(@TempDir Path tempDir) throws Exception { + Path webDir = tempDir.resolve("web"); + Path dispatchDir = tempDir.resolve("dispatch"); + Files.createDirectories(webDir); + Files.createDirectories(dispatchDir); + Files.writeString(webDir.resolve("Gateway.java"), """ + package com.example.web; + import com.example.dispatch.CentralDispatcher; + public class Gateway { + private final CentralDispatcher centralEventDispatcher; + public Gateway(CentralDispatcher centralEventDispatcher) { + this.centralEventDispatcher = centralEventDispatcher; + } + void execute(String command) { + centralEventDispatcher.dispatch(command); + } + } + """); + Files.writeString(dispatchDir.resolve("CentralDispatcher.java"), """ + package com.example.dispatch; + public class CentralDispatcher { + public void dispatch(String command) {} + } + """); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + List edges = graph.get("com.example.web.Gateway.execute"); + assertThat(edges).isNotEmpty(); + CallEdge dispatch = edges.stream() + .filter(e -> e.getTargetMethod().endsWith(".dispatch")) + .findFirst() + .orElseThrow(); + assertThat(dispatch.getReceiverTypeFqn()) + .isEqualTo("com.example.dispatch.CentralDispatcher"); + assertThat(dispatch.getTargetMethod()) + .isEqualTo("com.example.dispatch.CentralDispatcher.dispatch"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DfmAllArmsStringDispatchRegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DfmAllArmsStringDispatchRegressionTest.java new file mode 100644 index 0000000..dcaa759 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DfmAllArmsStringDispatchRegressionTest.java @@ -0,0 +1,36 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +/** + * DFM all-arms spike gate (Phase 5). + * + *

Mechanism (as of shared-switch refactor): teaching + * {@code JdtDataFlowModel} to treat unbound switch selectors as “collect every arm” + * via {@code SwitchConstSupport} improves poly in isolation but previously dropped + * string-dispatch export chains ({@link StringDispatchCallGraphGapTest}) when landed + * alone. Documented interactions to fix before merge: + *

    + *
  • {@code ENUM_SET} treated as enum-like for path constraints / storage
  • + *
  • call-graph {@code targetMethod} caller-package qualification + * (see {@link CallGraphReceiverTypeQualificationTest})
  • + *
  • path-walk type-compat treating {@code ENUM_SET:A.B,C.D} as a type FQN and + * skipping the edge (bare trigger param → {@code })
  • + *
+ * + *

Do not enable / merge DFM all-arms until this test is green with a real DFM + * all-arms implementation and the failure modes above are fixed using shared + * {@link click.kamil.springstatemachineexporter.ast.common.AstUtils} tokens + + * {@link click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver#concreteSwitchSelector}. + */ +@Disabled("DFM all-arms deferred until StringDispatch root cause is fixed under shared tokens/policy") +class DfmAllArmsStringDispatchRegressionTest { + + @Test + void dfmAllArmsMustNotDropStringDispatchPayChain() { + // Placeholder: when enabling DFM all-arms, replace with the StringDispatch gap assertion + // (or call that test) under the DFM change. + org.junit.jupiter.api.Assertions.fail("Enable only together with DFM all-arms + green StringDispatch"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnumExpansionHintBugHuntTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnumExpansionHintBugHuntTest.java new file mode 100644 index 0000000..ac2b331 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnumExpansionHintBugHuntTest.java @@ -0,0 +1,260 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Adversarial regression for shared {@code EnumExpansionHintSupport} wiring in ACE: + * expand when name/toUpperCase hints are on the arg chain; stay symbolic otherwise. + */ +class EnumExpansionHintBugHuntTest { + + @Test + void valueOfActionToUpperCaseExpandsToEnumMembers(@TempDir Path tempDir) throws IOException { + CallChain chain = resolveSendEvent(tempDir, """ + package com.example; + public class ApiController { + StateMachine sm; + public void processAction(String action) { + sm.sendEvent(OrderEvent.valueOf(action.toUpperCase())); + } + } + enum OrderEvent { PAY, SHIP, CANCEL } + class StateMachine { public void sendEvent(OrderEvent e) {} } + """, "processAction"); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .contains("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"); + } + + @Test + void valueOfActionToUpperCaseLocaleExpandsToEnumMembers(@TempDir Path tempDir) throws IOException { + CallChain chain = resolveSendEvent(tempDir, """ + package com.example; + import java.util.Locale; + public class ApiController { + StateMachine sm; + public void processAction(String action) { + sm.sendEvent(OrderEvent.valueOf(action.toUpperCase(Locale.ROOT))); + } + } + enum OrderEvent { PAY, SHIP, CANCEL } + class StateMachine { public void sendEvent(OrderEvent e) {} } + """, "processAction"); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .contains("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"); + } + + @Test + void plainValueOfUnboundStaysSymbolic(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class Endpoint { + private OrderService service; + public void trigger(String eventString) { + service.doSomething(eventString); + } + } + class OrderService { + public void doSomething(String str) { + MyEvents event = mapToEnum(str); + sendEvent(event); + } + private MyEvents mapToEnum(String str) { + return MyEvents.valueOf(str); + } + public void sendEvent(MyEvents e) {} + } + enum MyEvents { A, B } + """; + Files.writeString(tempDir.resolve("Endpoint.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.Endpoint") + .methodName("trigger") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("sendEvent") + .event("e") + .build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .containsExactly(""); + } + + @Test + void helperValueOfNameFillsPolymorphicEvents(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class LogisticsController { + private LogisticsService service; + public void handle(String event) { + service.process(event); + } + } + abstract class AbstractOrderService { + public void process(String event) { + String resolved = assertSupportedOrderEvent(event); + sendEvent(resolved); + } + protected abstract String assertSupportedOrderEvent(String event); + public void sendEvent(String ev) {} + } + class LogisticsService extends AbstractOrderService { + @Override + protected String assertSupportedOrderEvent(String event) { + return LogisticsEvent.valueOf(event).name(); + } + } + class ComputerStoreService extends AbstractOrderService { + @Override + protected String assertSupportedOrderEvent(String event) { + return ComputerEvent.valueOf(event).name(); + } + } + enum LogisticsEvent { DELIVERED, RETURNED } + enum ComputerEvent { SHIPPED, REFUNDED } + """; + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.LogisticsController") + .methodName("handle") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.AbstractOrderService") + .methodName("sendEvent") + .event("ev") + .build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrder("LogisticsEvent.DELIVERED", "LogisticsEvent.RETURNED"); + } + + @Test + void coincidentalNameInUnrelatedLocalMethodDoesNotFillEmptyPoly(@TempDir Path tempDir) throws IOException { + CallChain chain = resolveSendEvent(tempDir, """ + package com.example; + public class ApiController { + StateMachine sm; + public void process(String action) { + String ignored = debugLabel(); + sm.sendEvent(OrderEvent.valueOf(action)); + } + private String debugLabel() { + return OrderEvent.PAY.name(); + } + } + enum OrderEvent { PAY, SHIP, CANCEL } + class StateMachine { public void sendEvent(OrderEvent e) {} } + """, "process"); + List poly = chain.getTriggerPoint().getPolymorphicEvents(); + assertThat(poly) + .as("unrelated .name() helper must not fill empty poly with concretes, got %s", poly) + .noneMatch(e -> e != null && e.startsWith("OrderEvent.") && !e.contains("SYMBOLIC")); + } + + @Test + void multiTypeEnterpriseDispatcherStaysMultiSymbolic() throws IOException { + CodebaseContext context = new CodebaseContext(); + context.scan(Path.of("../state_machines/state_machine_enterprise")); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + EntryPoint entry = EntryPoint.builder() + .className("click.kamil.enterprise.web.StateMachineController") + .methodName("transition") + .name("POST /api/machine/{machineType}/transition/{event}") + .parameters(List.of( + EntryPoint.Parameter.builder() + .name("machineType") + .type("String") + .annotations(List.of("PathVariable")) + .build(), + EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("click.kamil.enterprise.web.StateMachineDispatcher") + .methodName("dispatch") + .event("event") + .build(); + List chains = builder.findChains(List.of(entry), List.of(trigger)); + assertThat(chains).isNotEmpty(); + Set polymorphicEvents = new LinkedHashSet<>(); + for (CallChain chain : chains) { + if (chain.getTriggerPoint().getPolymorphicEvents() != null) { + polymorphicEvents.addAll(chain.getTriggerPoint().getPolymorphicEvents()); + } + } + assertThat(polymorphicEvents) + .contains("", "", ""); + assertThat(polymorphicEvents.stream() + .filter(e -> e != null && !e.contains("SYMBOLIC") && e.contains("Event.")) + .toList()) + .as("must not flatten multi-type SYMBOLIC into concrete union: %s", polymorphicEvents) + .isEmpty(); + } + + @Test + void stringLiteralNameSubstringDoesNotFalseHint(@TempDir Path tempDir) throws IOException { + CallChain chain = resolveSendEvent(tempDir, """ + package com.example; + public class ApiController { + StateMachine sm; + public void process(String action) { + String note = ".name()"; + sm.sendEvent(OrderEvent.valueOf(action)); + } + } + enum OrderEvent { PAY, SHIP, CANCEL } + class StateMachine { public void sendEvent(OrderEvent e) {} } + """, "process"); + List poly = chain.getTriggerPoint().getPolymorphicEvents(); + assertThat(poly) + .as("string literal \".name()\" must not false-hint expand, got %s", poly) + .noneMatch(e -> "OrderEvent.PAY".equals(e) + || "OrderEvent.SHIP".equals(e) + || "OrderEvent.CANCEL".equals(e)); + } + + private static CallChain resolveSendEvent(Path tempDir, String source, String entryMethod) throws IOException { + Files.writeString(tempDir.resolve("ApiController.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.ApiController") + .methodName(entryMethod) + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("e") + .build(); + List chains = engine.findChains(List.of(entryPoint), List.of(trigger)); + assertThat(chains).isNotEmpty(); + return chains.get(0); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnumSetCallEdgePathWalkTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnumSetCallEdgePathWalkTest.java new file mode 100644 index 0000000..f79493f --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnumSetCallEdgePathWalkTest.java @@ -0,0 +1,63 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ENUM_SET on a call-edge argument must not be mistaken for a type FQN during path + * type-compat checks (otherwise the edge is skipped and poly collapses to SYMBOLIC). + */ +class EnumSetCallEdgePathWalkTest { + + @Test + void enumSetEdgeArgumentSurvivesPathWalkIntoPolymorphicEvents(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("OrderService.java"), """ + package com.example; + public class OrderService { + public void updateOrderState(String vv) { + StateMachine sm = new StateMachine(); + sm.sendEvent(getEventMultiValue(vv)); + } + + private OrderEvents getEventMultiValue(String q) { + return switch (q) { + case "a", "b" -> OrderEvents.A8; + case "c" -> OrderEvents.A133; + default -> OrderEvents.PAY; + }; + } + } + enum OrderEvents { A8, A133, PAY } + class StateMachine { + public void sendEvent(OrderEvents event) {} + } + """); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("event") + .build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrder("OrderEvents.A8", "OrderEvents.A133", "OrderEvents.PAY"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventIdSwitchProbeTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventIdSwitchProbeTest.java new file mode 100644 index 0000000..eabe37f --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventIdSwitchProbeTest.java @@ -0,0 +1,71 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +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; + +class EventIdSwitchProbeTest { + @Test + void probePoly(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class LogisticsController { + private LogisticsService service; + public void handle(String event) { + service.process(event); + } + } + abstract class AbstractOrderService { + public void process(String event) { + String resolved = assertSupportedOrderEvent(event); + sendEvent(resolved); + } + protected abstract String assertSupportedOrderEvent(String event); + public void sendEvent(String ev) {} + } + class LogisticsService extends AbstractOrderService { + @Override + protected String assertSupportedOrderEvent(String event) { + return LogisticsEvent.valueOf(event).name(); + } + } + class ComputerStoreService extends AbstractOrderService { + @Override + protected String assertSupportedOrderEvent(String event) { + return ComputerEvent.valueOf(event).name(); + } + } + enum LogisticsEvent { DELIVERED, RETURNED } + enum ComputerEvent { SHIPPED, REFUNDED } + """; + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.LogisticsController") + .methodName("handle") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.AbstractOrderService") + .methodName("sendEvent") + .event("ev") + .build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + System.out.println("CHAINS=" + chains.size()); + for (CallChain c : chains) { + System.out.println("CHAIN=" + c.getMethodChain()); + System.out.println("EVENT=" + c.getTriggerPoint().getEvent()); + System.out.println("POLY=" + c.getTriggerPoint().getPolymorphicEvents()); + System.out.println("CONSTRAINT=" + c.getTriggerPoint().getConstraint()); + } + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventIdentityResolverPipelineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventIdentityResolverPipelineTest.java index 295a67c..ac44808 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventIdentityResolverPipelineTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventIdentityResolverPipelineTest.java @@ -1211,6 +1211,234 @@ class EventIdentityResolverPipelineTest { assertFailClosed(source, tempDir, "pay"); } + @Test + void switchExpressionFromCodeResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(new PayRichEvent()); } + } + class Dispatcher { + StateMachine machine; + OrderEvent fromCode(String code) { + return switch (code) { + case "PAY" -> OrderEvent.PAY; + case "SHIP" -> OrderEvent.SHIP; + default -> throw new IllegalArgumentException(); + }; + } + void fire(RichEvent e) { + machine.sendEvent(fromCode(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void switchExpressionIncompleteStaysFailClosed(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(new PayRichEvent()); } + } + class Dispatcher { + StateMachine machine; + OrderEvent fromCode(String code) { + return switch (code) { + case "SHIP" -> OrderEvent.SHIP; + default -> throw new IllegalArgumentException(); + }; + } + void fire(RichEvent e) { + machine.sendEvent(fromCode(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertFailClosed(source, tempDir, "pay"); + } + + @Test + void messageGetPayloadResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + void fire() { + var msg = MessageBuilder.withPayload(OrderEvent.PAY).build(); + machine.sendEvent(msg.getPayload()); + } + } + class MessageBuilder { + static MessageBuilder withPayload(OrderEvent e) { return new MessageBuilder(); } + Message build() { return new Message(OrderEvent.PAY); } + } + class Message { + OrderEvent payload; + Message(OrderEvent p) { this.payload = p; } + OrderEvent getPayload() { return payload; } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void guardedInstanceofPatternResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(new Object()); } + } + class Dispatcher { + StateMachine machine; + void fire(Object o) { + if (o instanceof PayRichEvent p && p.getA() != null) { + machine.sendEvent(OrderEvent.valueOf(p.getA())); + } + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void castToConcreteWithoutInstanceofResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(new Object()); } + } + class Dispatcher { + StateMachine machine; + void fire(Object o) { + machine.sendEvent(OrderEvent.valueOf(((PayRichEvent) o).getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void methodReferenceSupplierResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.function.Supplier; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(dispatcher::payConst); } + } + class Dispatcher { + StateMachine machine; + OrderEvent payConst() { return OrderEvent.PAY; } + void fire(Supplier provider) { + machine.sendEvent(provider.get()); + } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void optionalOrElseSameConstResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.Optional; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + void fire() { + machine.sendEvent(Optional.of(OrderEvent.PAY).orElse(OrderEvent.PAY)); + } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void optionalOrElseDisagreeStaysFailClosed(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.Optional; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + void fire() { + machine.sendEvent(Optional.of(OrderEvent.PAY).orElse(OrderEvent.SHIP)); + } + } + """ + TAIL; + assertFailClosed(source, tempDir, "pay"); + } + + @Test + void uniqueCtorFieldWriteResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + RichEvent carrier; + Dispatcher() { this.carrier = new PayRichEvent(); } + void fire() { + machine.sendEvent(OrderEvent.valueOf(carrier.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + private static void assertFailClosed(String source, Path tempDir, String entryMethod) throws Exception { CodebaseContext context = scanSource(source, tempDir); Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY"); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolverMultiFileTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolverMultiFileTest.java new file mode 100644 index 0000000..8035caa --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolverMultiFileTest.java @@ -0,0 +1,74 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +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 static org.assertj.core.api.Assertions.assertThat; + +class PathBindingExpressionResolverMultiFileTest { + + @Test + void ctorInjectedMapperAcrossPackagesResolvesUnderBindings(@TempDir Path tempDir) throws Exception { + Path web = tempDir.resolve("web"); + Path dispatch = tempDir.resolve("dispatch"); + Files.createDirectories(web); + Files.createDirectories(dispatch); + Files.writeString(web.resolve("CommandGateway.java"), """ + package com.example.web; + import com.example.dispatch.StringCommandMapper; + import com.example.dispatch.DomainCommand; + public class CommandGateway { + private final StringCommandMapper stringCommandMapper; + public CommandGateway(StringCommandMapper stringCommandMapper) { + this.stringCommandMapper = stringCommandMapper; + } + public void executeViaMapper(String commandKey) { + DomainCommand command = stringCommandMapper.fromString(commandKey); + } + } + """); + Files.writeString(dispatch.resolve("StringCommandMapper.java"), """ + package com.example.dispatch; + public class StringCommandMapper { + public DomainCommand fromString(String commandKey) { + return switch (commandKey) { + case "order.pay" -> DomainCommand.ORDER_PAY; + case "order.ship" -> DomainCommand.ORDER_SHIP; + default -> throw new IllegalArgumentException(commandKey); + }; + } + } + """); + Files.writeString(dispatch.resolve("DomainCommand.java"), """ + package com.example.dispatch; + public enum DomainCommand { ORDER_PAY, ORDER_SHIP } + """); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + VariableTracer vt = new VariableTracer(context, context.getConstantResolver()); + vt.setTypeResolver(new TypeResolver(context)); + PathBindingExpressionResolver resolver = + new PathBindingExpressionResolver(context, vt, context.getConstantResolver()); + + String resolved = resolver.resolveMethodCallFromSource( + "com.example.web.CommandGateway.executeViaMapper", + "command", + Map.of("commandKey", "order.pay")); + assertThat(resolved).isEqualTo("DomainCommand.ORDER_PAY"); + + String traced = vt.traceLocalVariable( + "com.example.web.CommandGateway.executeViaMapper", + "command", + Map.of("commandKey", "order.pay")); + assertThat(traced).isEqualTo("DomainCommand.ORDER_PAY"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/AstUtilsAnalysisTokenTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/AstUtilsAnalysisTokenTest.java new file mode 100644 index 0000000..3c6bdc3 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/AstUtilsAnalysisTokenTest.java @@ -0,0 +1,39 @@ +package click.kamil.springstatemachineexporter.ast.common; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AstUtilsAnalysisTokenTest { + + @Test + void enumSetTrailingConstIsNotEnumLike() { + assertThat(AstUtils.isEnumLikeConstantName( + "ENUM_SET:DomainCommand.ORDER_PAY,DomainCommand.ORDER_SHIP")).isFalse(); + assertThat(AstUtils.isAnalysisToken( + "ENUM_SET:DomainCommand.ORDER_PAY,DomainCommand.ORDER_SHIP")).isTrue(); + } + + @Test + void symbolicWildcardIsNotEnumLike() { + assertThat(AstUtils.isEnumLikeConstantName("")).isFalse(); + assertThat(AstUtils.isAnalysisToken("")).isTrue(); + assertThat(AstUtils.isAnalysisToken("OrderEvents.*>")).isTrue(); + } + + @Test + void concreteEnumStillEnumLike() { + assertThat(AstUtils.isEnumLikeConstantName("DomainCommand.ORDER_PAY")).isTrue(); + assertThat(AstUtils.isConcreteConstant("DomainCommand.ORDER_PAY")).isTrue(); + } + + @Test + void enumSetFormatParseRoundTrip() { + String formatted = AstUtils.formatEnumSet(List.of("A.PAY", "A.SHIP")); + assertThat(formatted).isEqualTo("ENUM_SET:A.PAY,A.SHIP"); + assertThat(AstUtils.parseEnumSet(formatted)).containsExactly("A.PAY", "A.SHIP"); + assertThat(AstUtils.parseEnumSet("not-a-set")).isEmpty(); + } +}