This commit is contained in:
2026-07-18 21:15:01 +02:00
parent 5081715e13
commit 61a04241c8
29 changed files with 2735 additions and 224 deletions

View File

@@ -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<String> methodPath,
Map<String, List<CallEdge>> callGraph,
int depth,
Set<String> visited) {
if (owner == null || fieldName == null) {
return ReceiverFacts.empty();
}
LinkedHashSet<AllocationSite> 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<Expression> 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.
*/

View File

@@ -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.
* <ul>
* <li>instanceof empty → base unchanged</li>
* <li>base empty → instanceof</li>
* <li>base unique is Object / missing TD / interface / abstract → refine to instanceof</li>
* <li>base unique concrete equals instanceof → that site</li>
* <li>base unique concrete conflicts with instanceof → empty (fail-closed)</li>
* </ul>
* 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<Expression> flattenAndConditions(Expression condition) {
List<Expression> parts = new ArrayList<>();
collectAndParts(condition, parts);
if (parts.isEmpty() && condition != null) {
parts.add(condition);
}
return parts;
}
private static void collectAndParts(Expression expression, List<Expression> 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());

View File

@@ -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<String> elseArm = resolveUnique(elseArg, methodPath, callGraph, depth + 1, visited);
Expression present = peelOptionalPresent(orElseMi.getExpression());
if (present != null && elseArm.size() == 1) {
List<String> 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<String> resolveFromSwitchExpression(
SwitchExpression se,
List<String> methodPath,
Map<String, List<CallEdge>> callGraph,
int depth,
Set<String> 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<String> 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<String, String> caseToConst = new HashMap<>();
ingestSwitchCases(se.statements(), caseToConst);
if (caseToConst.isEmpty()) {
return List.of();
}
List<String> 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<String, String> extractStringCaseMap(MethodDeclaration method) {
Map<String, String> 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<String, String> map) {
Map<String, Expression> payloads = SwitchConstSupport.stringCasePayloads(
statements,
this::resolveSwitchCaseKey);
for (Map.Entry<String, Expression> 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<String> 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<String, List<CallEdge>> callGraph,
int depth,
Set<String> 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<String> resolveFromMethodReference(
Expression methodRef,
List<String> methodPath,
Map<String, List<CallEdge>> callGraph,
int depth,
Set<String> 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<String> proven = new LinkedHashSet<>();
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
List<String> 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;
}
}

View File

@@ -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 <SYMBOLIC: Enum.*>} to concrete enum members (ACE poly fill).
* <p>
* 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;
}
}

View File

@@ -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);

View File

@@ -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<Expression> armPayloadExpressions(List<?> statements) {
List<Expression> 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<Expression> 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<String, Expression> stringCasePayloads(
List<?> statements,
Function<Expression, String> caseKeyResolver) {
Map<String, Expression> 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<String, Expression> stringCasePayloads(
ASTNode switchNode,
Function<Expression, String> 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;
}
}

View File

@@ -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;
}

View File

@@ -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<String, String> 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<String, String> 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<String, String> 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<String> 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<String, String> paramValues, CodebaseContext context, Set<String> 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<String> 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<String, String> paramValues, CodebaseContext context, Set<String> 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<String> 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<String, String> paramValues, CodebaseContext context, Set<String> visited) {
if (expr instanceof SimpleName sn) {

View File

@@ -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<String> path,
Map<String, List<CallEdge>> 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<String> fromCaller = resolvePolymorphicEventsFromCallerArgument(path);
if (fromCaller.stream().anyMatch(this::looksLikeEnumConstant)) {
return fromCaller;
}
}
return polymorphicEvents;
}
Map<String, String> bindings = resolvePathBindings(path, callGraph, Map.of());
if (bindings.isEmpty()) {
boolean symbolicOnly = polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant);
if (symbolicOnly) {
List<String> 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<String> 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<String> 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<String> 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<Expression> 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<String> inferEnumValuesFromPath(List<String> 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<String> path, String methodName) {
if (path == null || methodName == null) {
return null;

View File

@@ -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("<SYMBOLIC:") || resolved.contains("SYMBOLIC:") || resolved.endsWith(".*>"))) {
return false;
}
return expr instanceof SwitchExpression
|| expr instanceof ConditionalExpression;
return expr instanceof ConditionalExpression;
}
public void extractConstantsFromArgument(Expression argObj, List<String> 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<Expression> tracedRetExprs = variableTracer.traceVariableAll(retExpr);
@@ -569,25 +594,12 @@ public class ConstantExtractor {
}
private void extractConstantsFromSwitchLike(ASTNode switchNode, List<String> 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) {

View File

@@ -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());
}

View File

@@ -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<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
List<CallEdge> edges = callGraph.get(caller);

View File

@@ -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<String, String> 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("<SYMBOLIC:")
|| value.contains("SYMBOLIC:")
|| value.endsWith(".*>")) {
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();
}
}
}

View File

@@ -336,6 +336,21 @@ public class VariableTracer {
List<String> 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<String, String> 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<String, String> 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<String, String> branchBindings) {
if (expr instanceof SwitchExpression se) {
Map<String, String> 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<Expression> defs = traceVariableAll(expr);
if (defs.size() <= 1) {
return null;
}
LinkedHashSet<String> 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<String, String> 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;
}

View File

@@ -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 <SYMBOLIC:…>}, 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("<SYMBOLIC:")) {
return true;
}
if (value.contains("SYMBOLIC:")) {
return true;
}
return value.endsWith(".*>");
}
/** 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<String> 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<String> 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<String> 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;

View File

@@ -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, """

View File

@@ -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];
}
}

View File

@@ -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, """

View File

@@ -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<String> kinds = new java.util.ArrayList<>();
for (Object o : se[0].statements()) {
kinds.add(o.getClass().getSimpleName() + ":" + o.toString().replace('\n', ' '));
}
List<Expression> 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<Expression> 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<Expression> 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<String, Expression> 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];
}
}

View File

@@ -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(

View File

@@ -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<String, String> 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;
}
}

View File

@@ -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<String, List<CallEdge>> graph = engine.buildCallGraph();
List<CallEdge> 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");
}
}

View File

@@ -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).
*
* <p><b>Mechanism (as of shared-switch refactor):</b> 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:
* <ul>
* <li>{@code ENUM_SET} treated as enum-like for path constraints / storage</li>
* <li>call-graph {@code targetMethod} caller-package qualification
* (see {@link CallGraphReceiverTypeQualificationTest})</li>
* <li>path-walk type-compat treating {@code ENUM_SET:A.B,C.D} as a type FQN and
* skipping the edge (bare trigger param → {@code <SYMBOLIC:…>})</li>
* </ul>
*
* <p>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");
}
}

View File

@@ -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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("<SYMBOLIC: com.example.MyEvents.*>");
}
@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<CallChain> 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<String> 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<CallChain> chains = builder.findChains(List.of(entry), List.of(trigger));
assertThat(chains).isNotEmpty();
Set<String> polymorphicEvents = new LinkedHashSet<>();
for (CallChain chain : chains) {
if (chain.getTriggerPoint().getPolymorphicEvents() != null) {
polymorphicEvents.addAll(chain.getTriggerPoint().getPolymorphicEvents());
}
}
assertThat(polymorphicEvents)
.contains("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: DocumentEvent.*>", "<SYMBOLIC: UserEvent.*>");
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<String> 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<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).isNotEmpty();
return chains.get(0);
}
}

View File

@@ -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<CallChain> 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");
}
}

View File

@@ -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<CallChain> 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());
}
}
}

View File

@@ -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<OrderEvent> 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");

View File

@@ -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");
}
}

View File

@@ -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("<SYMBOLIC: OrderEvents.*>")).isFalse();
assertThat(AstUtils.isAnalysisToken("<SYMBOLIC: OrderEvents.*>")).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();
}
}