F
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()) {
|
||||
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());
|
||||
|
||||
@@ -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,9 +1131,7 @@ public final class EventIdentityResolver {
|
||||
if (owner == null) {
|
||||
owner = findEnclosingType(call);
|
||||
}
|
||||
if (owner == null) {
|
||||
return null;
|
||||
}
|
||||
if (owner != null) {
|
||||
MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true);
|
||||
if (md != null) {
|
||||
return md;
|
||||
@@ -1019,6 +1149,24 @@ public final class EventIdentityResolver {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,12 +517,16 @@ 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);
|
||||
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(",")));
|
||||
@@ -478,28 +534,9 @@ public class ConstantResolver {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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,12 +592,17 @@ 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);
|
||||
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(",")));
|
||||
@@ -565,28 +610,9 @@ public class ConstantResolver {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
if (SwitchConstSupport.hasOpaqueArm(switchNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean visit(ExpressionStatement es) {
|
||||
extractConstantsFromExpression(es.getExpression(), constants);
|
||||
return super.visit(es);
|
||||
for (Expression armExpr : SwitchConstSupport.armPayloadExpressions(switchNode)) {
|
||||
extractConstantsFromExpression(armExpr, constants);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean visit(ReturnStatement rs) {
|
||||
extractConstantsFromExpression(rs.getExpression(), constants);
|
||||
return super.visit(rs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isKeyedLookupMethod(MethodInvocation mi) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, """
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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, """
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user