F
This commit is contained in:
@@ -349,7 +349,11 @@ public final class AllocationFactPropagator {
|
|||||||
sites.addAll(nested.sites());
|
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();
|
return ReceiverFacts.empty();
|
||||||
}
|
}
|
||||||
// Ambiguous: multiple writes that disagree → fail closed via ofAll size; also reject
|
// 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.
|
* 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.CastExpression;
|
||||||
import org.eclipse.jdt.core.dom.Expression;
|
import org.eclipse.jdt.core.dom.Expression;
|
||||||
import org.eclipse.jdt.core.dom.IfStatement;
|
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.InstanceofExpression;
|
||||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
import org.eclipse.jdt.core.dom.Modifier;
|
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.TypeDeclaration;
|
||||||
import org.eclipse.jdt.core.dom.TypePattern;
|
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,
|
* Shared by {@link AllocationFactPropagator}; fail-closed for else-branch, abstract targets,
|
||||||
* and sends outside the narrow.
|
* and sends outside the narrow.
|
||||||
*/
|
*/
|
||||||
@@ -66,14 +70,25 @@ public final class InstanceofNarrower {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge base AFP facts with dominating instanceof.
|
* Facts from a {@code (ConcreteType) expr} cast wrapping the receiver expression itself.
|
||||||
* <ul>
|
*/
|
||||||
* <li>instanceof empty → base unchanged</li>
|
public static ReceiverFacts factsFromCast(Expression receiver, CodebaseContext context) {
|
||||||
* <li>base empty → instanceof</li>
|
if (receiver == null || context == null) {
|
||||||
* <li>base unique is Object / missing TD / interface / abstract → refine to instanceof</li>
|
return ReceiverFacts.empty();
|
||||||
* <li>base unique concrete equals instanceof → that site</li>
|
}
|
||||||
* <li>base unique concrete conflicts with instanceof → empty (fail-closed)</li>
|
Expression current = receiver;
|
||||||
* </ul>
|
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(
|
public static ReceiverFacts refine(
|
||||||
ReceiverFacts base,
|
ReceiverFacts base,
|
||||||
@@ -81,22 +96,33 @@ public final class InstanceofNarrower {
|
|||||||
ASTNode useSite,
|
ASTNode useSite,
|
||||||
CodebaseContext context) {
|
CodebaseContext context) {
|
||||||
ReceiverFacts narrowed = factsAt(receiver, useSite, context);
|
ReceiverFacts narrowed = factsAt(receiver, useSite, context);
|
||||||
if (narrowed.isEmpty()) {
|
if (!narrowed.isEmpty()) {
|
||||||
return base == null ? ReceiverFacts.empty() : base;
|
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()) {
|
if (base == null || base.isEmpty()) {
|
||||||
return narrowed;
|
return refined;
|
||||||
}
|
}
|
||||||
AllocationSite baseUnique = base.uniqueOrNull();
|
AllocationSite baseUnique = base.uniqueOrNull();
|
||||||
AllocationSite narrowUnique = narrowed.uniqueOrNull();
|
AllocationSite refineUnique = refined.uniqueOrNull();
|
||||||
if (baseUnique == null || narrowUnique == null) {
|
if (baseUnique == null || refineUnique == null) {
|
||||||
return ReceiverFacts.empty();
|
return ReceiverFacts.empty();
|
||||||
}
|
}
|
||||||
if (isWeakAllocSite(baseUnique, context)) {
|
if (isWeakAllocSite(baseUnique, context)) {
|
||||||
return narrowed;
|
return refined;
|
||||||
}
|
}
|
||||||
if (sameType(baseUnique.typeFqn(), narrowUnique.typeFqn())) {
|
if (sameType(baseUnique.typeFqn(), refineUnique.typeFqn())) {
|
||||||
return ReceiverFacts.of(baseUnique.preferRich(narrowUnique));
|
return ReceiverFacts.of(baseUnique.preferRich(refineUnique));
|
||||||
}
|
}
|
||||||
return ReceiverFacts.empty();
|
return ReceiverFacts.empty();
|
||||||
}
|
}
|
||||||
@@ -158,6 +184,41 @@ public final class InstanceofNarrower {
|
|||||||
Expression condition,
|
Expression condition,
|
||||||
String recvName,
|
String recvName,
|
||||||
CodebaseContext context) {
|
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);
|
Expression cond = peel(condition);
|
||||||
if (cond instanceof InstanceofExpression io) {
|
if (cond instanceof InstanceofExpression io) {
|
||||||
Expression left = peel(io.getLeftOperand());
|
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.MessageCarrierSupport;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
|
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.resolver.ConstantResolver;
|
||||||
import click.kamil.springstatemachineexporter.analysis.service.FunctionalInterfaceTypes;
|
import click.kamil.springstatemachineexporter.analysis.service.FunctionalInterfaceTypes;
|
||||||
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
|
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.ClassInstanceCreation;
|
||||||
import org.eclipse.jdt.core.dom.ConditionalExpression;
|
import org.eclipse.jdt.core.dom.ConditionalExpression;
|
||||||
import org.eclipse.jdt.core.dom.Expression;
|
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.FieldAccess;
|
||||||
import org.eclipse.jdt.core.dom.FieldDeclaration;
|
import org.eclipse.jdt.core.dom.FieldDeclaration;
|
||||||
import org.eclipse.jdt.core.dom.ITypeBinding;
|
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.SingleVariableDeclaration;
|
||||||
import org.eclipse.jdt.core.dom.StringLiteral;
|
import org.eclipse.jdt.core.dom.StringLiteral;
|
||||||
import org.eclipse.jdt.core.dom.SwitchCase;
|
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.SwitchStatement;
|
||||||
|
import org.eclipse.jdt.core.dom.ThisExpression;
|
||||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
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.VariableDeclarationFragment;
|
||||||
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
|
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
|
||||||
|
import org.eclipse.jdt.core.dom.YieldStatement;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -335,6 +342,28 @@ public final class EventIdentityResolver {
|
|||||||
return List.of();
|
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)
|
// 3. Enum.valueOf(expr) or Enum.valueOf(Class, String)
|
||||||
if (peeled instanceof MethodInvocation valueOfMi
|
if (peeled instanceof MethodInvocation valueOfMi
|
||||||
&& "valueOf".equals(valueOfMi.getName().getIdentifier())) {
|
&& "valueOf".equals(valueOfMi.getName().getIdentifier())) {
|
||||||
@@ -512,7 +541,7 @@ public final class EventIdentityResolver {
|
|||||||
if (!visited.add(key) || helperCall.arguments().isEmpty()) {
|
if (!visited.add(key) || helperCall.arguments().isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
MethodDeclaration helperMd = resolveHelperMethod(helperCall);
|
MethodDeclaration helperMd = resolveHelperMethod(helperCall, methodPath);
|
||||||
if (helperMd == null) {
|
if (helperMd == null) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
@@ -538,7 +567,7 @@ public final class EventIdentityResolver {
|
|||||||
if (!visited.add(key) || helperCall.arguments().isEmpty()) {
|
if (!visited.add(key) || helperCall.arguments().isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
MethodDeclaration helperMd = resolveHelperMethod(helperCall);
|
MethodDeclaration helperMd = resolveHelperMethod(helperCall, methodPath);
|
||||||
if (helperMd == null || helperMd.getBody() == null || helperMd.parameters().isEmpty()) {
|
if (helperMd == null || helperMd.getBody() == null || helperMd.parameters().isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
@@ -572,6 +601,93 @@ public final class EventIdentityResolver {
|
|||||||
return looksLikeEnumConstant(mapped) ? List.of(mapped) : List.of();
|
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) {
|
private Map<String, String> extractStringCaseMap(MethodDeclaration method) {
|
||||||
Map<String, String> map = new HashMap<>();
|
Map<String, String> map = new HashMap<>();
|
||||||
if (method.getBody() == null) {
|
if (method.getBody() == null) {
|
||||||
@@ -580,35 +696,13 @@ public final class EventIdentityResolver {
|
|||||||
method.getBody().accept(new ASTVisitor() {
|
method.getBody().accept(new ASTVisitor() {
|
||||||
@Override
|
@Override
|
||||||
public boolean visit(SwitchStatement node) {
|
public boolean visit(SwitchStatement node) {
|
||||||
String currentKey = null;
|
ingestSwitchCases(node.statements(), map);
|
||||||
for (Object stmtObj : node.statements()) {
|
return false;
|
||||||
if (stmtObj instanceof SwitchCase sc) {
|
}
|
||||||
if (sc.isDefault()) {
|
|
||||||
currentKey = null;
|
@Override
|
||||||
continue;
|
public boolean visit(SwitchExpression node) {
|
||||||
}
|
ingestSwitchCases(node.statements(), map);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,11 +727,45 @@ public final class EventIdentityResolver {
|
|||||||
return map;
|
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) {
|
private String resolveReturnConstant(Expression expression) {
|
||||||
if (expression == null || constantResolver == null) {
|
if (expression == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
Expression peeled = peel(expression);
|
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 direct = constantResolver.resolve(peeled, context);
|
||||||
String normalized = VirtualAccessorConstResolver.normalizeConstant(direct);
|
String normalized = VirtualAccessorConstResolver.normalizeConstant(direct);
|
||||||
if (normalized != null && looksLikeEnumConstant(normalized)) {
|
if (normalized != null && looksLikeEnumConstant(normalized)) {
|
||||||
@@ -791,7 +919,7 @@ public final class EventIdentityResolver {
|
|||||||
if (!visited.add(key) || helperCall.arguments().isEmpty()) {
|
if (!visited.add(key) || helperCall.arguments().isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
MethodDeclaration helperMd = resolveHelperMethod(helperCall);
|
MethodDeclaration helperMd = resolveHelperMethod(helperCall, methodPath);
|
||||||
if (helperMd == null || helperMd.getBody() == null) {
|
if (helperMd == null || helperMd.getBody() == null) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
@@ -983,6 +1111,10 @@ public final class EventIdentityResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private MethodDeclaration resolveHelperMethod(MethodInvocation call) {
|
private MethodDeclaration resolveHelperMethod(MethodInvocation call) {
|
||||||
|
return resolveHelperMethod(call, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MethodDeclaration resolveHelperMethod(MethodInvocation call, List<String> methodPath) {
|
||||||
String methodName = call.getName().getIdentifier();
|
String methodName = call.getName().getIdentifier();
|
||||||
TypeDeclaration owner = null;
|
TypeDeclaration owner = null;
|
||||||
if (call.getExpression() == null) {
|
if (call.getExpression() == null) {
|
||||||
@@ -999,25 +1131,41 @@ public final class EventIdentityResolver {
|
|||||||
if (owner == null) {
|
if (owner == null) {
|
||||||
owner = findEnclosingType(call);
|
owner = findEnclosingType(call);
|
||||||
}
|
}
|
||||||
if (owner == null) {
|
if (owner != null) {
|
||||||
return null;
|
MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true);
|
||||||
}
|
|
||||||
MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true);
|
|
||||||
if (md != null) {
|
|
||||||
return md;
|
|
||||||
}
|
|
||||||
String superFqn = context.getSuperclassFqn(owner);
|
|
||||||
int hops = 0;
|
|
||||||
while (superFqn != null && hops++ < 8) {
|
|
||||||
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
|
||||||
if (superTd == null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
md = context.findMethodDeclaration(superTd, methodName, true);
|
|
||||||
if (md != null) {
|
if (md != null) {
|
||||||
return md;
|
return md;
|
||||||
}
|
}
|
||||||
superFqn = context.getSuperclassFqn(superTd);
|
String superFqn = context.getSuperclassFqn(owner);
|
||||||
|
int hops = 0;
|
||||||
|
while (superFqn != null && hops++ < 8) {
|
||||||
|
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
||||||
|
if (superTd == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
md = context.findMethodDeclaration(superTd, methodName, true);
|
||||||
|
if (md != null) {
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
superFqn = context.getSuperclassFqn(superTd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Detached / string-parsed invocations have no enclosing type — walk the call path.
|
||||||
|
if (methodPath != null) {
|
||||||
|
for (String methodFqn : methodPath) {
|
||||||
|
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String classFqn = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(classFqn);
|
||||||
|
if (td == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||||
|
if (md != null) {
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1223,7 +1371,19 @@ public final class EventIdentityResolver {
|
|||||||
Map<String, List<CallEdge>> callGraph,
|
Map<String, List<CallEdge>> callGraph,
|
||||||
int depth,
|
int depth,
|
||||||
Set<String> visited) {
|
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) {
|
if (eventExpr == null || eventExpr == peeled) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
@@ -1249,12 +1409,149 @@ public final class EventIdentityResolver {
|
|||||||
}
|
}
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
if (binding instanceof ExpressionMethodReference || binding instanceof TypeMethodReference) {
|
||||||
|
return resolveFromMethodReference(binding, methodPath, callGraph, depth, visited);
|
||||||
|
}
|
||||||
if (binding != null && binding != getCall.getExpression()) {
|
if (binding != null && binding != getCall.getExpression()) {
|
||||||
return resolveUnique(binding, methodPath, callGraph, depth + 1, visited);
|
return resolveUnique(binding, methodPath, callGraph, depth + 1, visited);
|
||||||
}
|
}
|
||||||
return List.of();
|
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(
|
private Expression findFunctionalBinding(
|
||||||
Expression receiver,
|
Expression receiver,
|
||||||
MethodDeclaration enclosing,
|
MethodDeclaration enclosing,
|
||||||
@@ -1264,6 +1561,9 @@ public final class EventIdentityResolver {
|
|||||||
if (peeled instanceof LambdaExpression lambda) {
|
if (peeled instanceof LambdaExpression lambda) {
|
||||||
return lambda;
|
return lambda;
|
||||||
}
|
}
|
||||||
|
if (peeled instanceof ExpressionMethodReference || peeled instanceof TypeMethodReference) {
|
||||||
|
return peeled;
|
||||||
|
}
|
||||||
if (!(peeled instanceof SimpleName sn) || enclosing == null) {
|
if (!(peeled instanceof SimpleName sn) || enclosing == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1274,7 +1574,9 @@ public final class EventIdentityResolver {
|
|||||||
if (variableTracer != null) {
|
if (variableTracer != null) {
|
||||||
for (Expression def : variableTracer.traceVariableAll(peeled)) {
|
for (Expression def : variableTracer.traceVariableAll(peeled)) {
|
||||||
Expression defPeeled = peel(def);
|
Expression defPeeled = peel(def);
|
||||||
if (defPeeled instanceof LambdaExpression) {
|
if (defPeeled instanceof LambdaExpression
|
||||||
|
|| defPeeled instanceof ExpressionMethodReference
|
||||||
|
|| defPeeled instanceof TypeMethodReference) {
|
||||||
return defPeeled;
|
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.
|
* 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 {
|
public final class MessageCarrierSupport {
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ public final class MessageCarrierSupport {
|
|||||||
/**
|
/**
|
||||||
* Prefer allowlisted {@code setHeader("event"|"smEvent", …)} on a MessageBuilder chain;
|
* Prefer allowlisted {@code setHeader("event"|"smEvent", …)} on a MessageBuilder chain;
|
||||||
* else peel {@code withPayload}/{@code just}/{@code success}; else GenericMessage ctor arg.
|
* 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
|
* @return the event-bearing expression, or null when nothing is proven
|
||||||
*/
|
*/
|
||||||
@@ -27,6 +28,12 @@ public final class MessageCarrierSupport {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
Expression peeled = peel(expression);
|
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);
|
Expression headerEvent = findSetHeaderValueOnChain(peeled, null);
|
||||||
if (headerEvent != null) {
|
if (headerEvent != null) {
|
||||||
return peel(headerEvent);
|
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)) {
|
if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isAnalysisToken(boundValue)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (isIncompleteDottedName(boundValue)) {
|
if (isIncompleteDottedName(boundValue)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,55 @@ public class ConstantResolver {
|
|||||||
return evaluateSwitchStatement(ss, paramValues, context, new HashSet<>());
|
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) {
|
private String resolveInternal(Expression expr, CodebaseContext context, Set<String> visited) {
|
||||||
if (expr == null) return null;
|
if (expr == null) return null;
|
||||||
|
|
||||||
@@ -71,6 +120,9 @@ public class ConstantResolver {
|
|||||||
if (expr instanceof ParenthesizedExpression pe) {
|
if (expr instanceof ParenthesizedExpression pe) {
|
||||||
return resolveInternal(pe.getExpression(), context, visited);
|
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) {
|
if (expr instanceof ArrayAccess access) {
|
||||||
String indexed = LiteralExpressionSupport.resolveArrayAccess(
|
String indexed = LiteralExpressionSupport.resolveArrayAccess(
|
||||||
access, context, visited, (e, v) -> resolveInternal(e, context, v));
|
access, context, visited, (e, v) -> resolveInternal(e, context, v));
|
||||||
@@ -465,41 +517,26 @@ public class ConstantResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String evaluateSwitchStatement(org.eclipse.jdt.core.dom.SwitchStatement ss, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
|
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 (switchVar == null) {
|
||||||
|
if (click.kamil.springstatemachineexporter.analysis.pipeline.SwitchConstSupport.hasOpaqueArm(ss)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Set<String> values = new LinkedHashSet<>();
|
Set<String> values = new LinkedHashSet<>();
|
||||||
for (Object stmtObj : ss.statements()) {
|
for (Expression armExpr : click.kamil.springstatemachineexporter.analysis.pipeline.SwitchConstSupport.armPayloadExpressions(ss)) {
|
||||||
if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs && rs.getExpression() != null) {
|
String val = resolveInternal(armExpr, context, visited);
|
||||||
String val = resolveInternal(rs.getExpression(), context, visited);
|
if (val != null) {
|
||||||
if (val != null) {
|
if (val.startsWith("ENUM_SET:")) {
|
||||||
if (val.startsWith("ENUM_SET:")) {
|
values.addAll(Arrays.asList(val.substring(9).split(",")));
|
||||||
values.addAll(Arrays.asList(val.substring(9).split(",")));
|
} else {
|
||||||
} else {
|
values.add(val);
|
||||||
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()) {
|
if (!values.isEmpty()) {
|
||||||
return "ENUM_SET:" + String.join(",", values);
|
return click.kamil.springstatemachineexporter.ast.common.AstUtils.formatEnumSet(values);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -510,11 +547,12 @@ public class ConstantResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
boolean matchingCase = false;
|
boolean matchingCase = false;
|
||||||
|
boolean matchedNonDefault = false;
|
||||||
for (Object stmtObj : ss.statements()) {
|
for (Object stmtObj : ss.statements()) {
|
||||||
if (stmtObj instanceof org.eclipse.jdt.core.dom.SwitchCase sc) {
|
if (stmtObj instanceof org.eclipse.jdt.core.dom.SwitchCase sc) {
|
||||||
matchingCase = false;
|
matchingCase = false;
|
||||||
if (sc.isDefault()) {
|
if (sc.isDefault()) {
|
||||||
matchingCase = true;
|
matchingCase = !matchedNonDefault;
|
||||||
} else {
|
} else {
|
||||||
for (Object exprObj : sc.expressions()) {
|
for (Object exprObj : sc.expressions()) {
|
||||||
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context, visited);
|
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context, visited);
|
||||||
@@ -529,10 +567,12 @@ public class ConstantResolver {
|
|||||||
if (switchVar.contains(".") && caseVal.contains(".")) {
|
if (switchVar.contains(".") && caseVal.contains(".")) {
|
||||||
if (switchVar.equals(caseVal)) {
|
if (switchVar.equals(caseVal)) {
|
||||||
matchingCase = true;
|
matchingCase = true;
|
||||||
|
matchedNonDefault = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if (switchVar.endsWith(caseVal)) {
|
} else if (switchVar.endsWith(caseVal)) {
|
||||||
matchingCase = true;
|
matchingCase = true;
|
||||||
|
matchedNonDefault = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,41 +592,27 @@ public class ConstantResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String evaluateSwitchExpression(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
|
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 (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<>();
|
Set<String> values = new LinkedHashSet<>();
|
||||||
for (Object stmtObj : se.statements()) {
|
for (Expression armExpr : click.kamil.springstatemachineexporter.analysis.pipeline.SwitchConstSupport.armPayloadExpressions(se)) {
|
||||||
if (stmtObj instanceof org.eclipse.jdt.core.dom.YieldStatement ys && ys.getExpression() != null) {
|
String val = resolveInternal(armExpr, context, visited);
|
||||||
String val = resolveInternal(ys.getExpression(), context, visited);
|
if (val != null) {
|
||||||
if (val != null) {
|
if (val.startsWith("ENUM_SET:")) {
|
||||||
if (val.startsWith("ENUM_SET:")) {
|
values.addAll(Arrays.asList(val.substring(9).split(",")));
|
||||||
values.addAll(Arrays.asList(val.substring(9).split(",")));
|
} else {
|
||||||
} else {
|
values.add(val);
|
||||||
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()) {
|
if (!values.isEmpty()) {
|
||||||
return "ENUM_SET:" + String.join(",", values);
|
return click.kamil.springstatemachineexporter.ast.common.AstUtils.formatEnumSet(values);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -597,15 +623,15 @@ public class ConstantResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
boolean matchingCase = false;
|
boolean matchingCase = false;
|
||||||
|
boolean matchedNonDefault = false;
|
||||||
for (Object stmtObj : se.statements()) {
|
for (Object stmtObj : se.statements()) {
|
||||||
if (stmtObj instanceof org.eclipse.jdt.core.dom.SwitchCase sc) {
|
if (stmtObj instanceof org.eclipse.jdt.core.dom.SwitchCase sc) {
|
||||||
matchingCase = false;
|
matchingCase = false;
|
||||||
if (sc.isDefault()) {
|
if (sc.isDefault()) {
|
||||||
matchingCase = true;
|
matchingCase = !matchedNonDefault;
|
||||||
} else {
|
} else {
|
||||||
for (Object exprObj : sc.expressions()) {
|
for (Object exprObj : sc.expressions()) {
|
||||||
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context, visited);
|
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) {
|
if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) {
|
||||||
caseVal = caseSn.getIdentifier();
|
caseVal = caseSn.getIdentifier();
|
||||||
if (switchType != null) {
|
if (switchType != null) {
|
||||||
@@ -616,28 +642,61 @@ public class ConstantResolver {
|
|||||||
if (switchVar.contains(".") && caseVal.contains(".")) {
|
if (switchVar.contains(".") && caseVal.contains(".")) {
|
||||||
if (switchVar.equals(caseVal)) {
|
if (switchVar.equals(caseVal)) {
|
||||||
matchingCase = true;
|
matchingCase = true;
|
||||||
|
matchedNonDefault = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if (switchVar.endsWith(caseVal)) {
|
} else if (switchVar.endsWith(caseVal)) {
|
||||||
matchingCase = true;
|
matchingCase = true;
|
||||||
|
matchedNonDefault = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (matchingCase) {
|
} else if (matchingCase) {
|
||||||
|
Expression armExpr = null;
|
||||||
if (stmtObj instanceof org.eclipse.jdt.core.dom.ExpressionStatement es) {
|
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) {
|
} 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) {
|
} 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;
|
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(
|
private String resolveExpressionWithParams(
|
||||||
Expression expr, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
|
Expression expr, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
|
||||||
if (expr instanceof SimpleName sn) {
|
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.flow.eventid.EventIdentityResolver;
|
||||||
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
|
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
|
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.FieldInitializerFinder;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
|
||||||
@@ -442,13 +443,18 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
String argValue = edge.getArguments().get(paramIndex);
|
String argValue = edge.getArguments().get(paramIndex);
|
||||||
if (!FunctionalInterfaceTypes.isProvablyResolvedCallSiteArgument(argValue, expectedType)) {
|
if (!FunctionalInterfaceTypes.isProvablyResolvedCallSiteArgument(argValue, expectedType)) {
|
||||||
String actualType = null;
|
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('.'));
|
String prefix = argValue.substring(0, argValue.lastIndexOf('.'));
|
||||||
if (!prefix.equals("this") && !prefix.equals("super")) {
|
if (!prefix.equals("this") && !prefix.equals("super")) {
|
||||||
actualType = prefix;
|
actualType = prefix;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (actualType == null && !argValue.contains(".")) {
|
if (actualType == null && argValue != null && !argValue.contains(".")) {
|
||||||
actualType = variableTracer.getVariableDeclaredType(caller, argValue);
|
actualType = variableTracer.getVariableDeclaredType(caller, argValue);
|
||||||
}
|
}
|
||||||
if (actualType != null && !typeResolver.isTypeCompatible(actualType, expectedType)) {
|
if (actualType != null && !typeResolver.isTypeCompatible(actualType, expectedType)) {
|
||||||
@@ -1553,6 +1559,34 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
return null;
|
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
|
* 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.
|
* receiver type so pathfinding can prune sibling overrides inside that collaborator hierarchy.
|
||||||
@@ -2898,11 +2932,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
declaredReceiverType = injectedReceiverType;
|
declaredReceiverType = injectedReceiverType;
|
||||||
}
|
}
|
||||||
for (String calledMethod : calledMethods) {
|
for (String calledMethod : calledMethods) {
|
||||||
|
String qualifiedTarget = qualifyCalledMethodWithReceiverType(
|
||||||
|
calledMethod, declaredReceiverType);
|
||||||
String receiverTypeFqn =
|
String receiverTypeFqn =
|
||||||
refineReceiverTypeForTarget(declaredReceiverType, calledMethod);
|
refineReceiverTypeForTarget(declaredReceiverType, qualifiedTarget);
|
||||||
CallEdge edge = new CallEdge(
|
CallEdge edge = new CallEdge(
|
||||||
calledMethod, args, receiver, null, receiverTypeFqn);
|
qualifiedTarget, args, receiver, null, receiverTypeFqn);
|
||||||
edge.setConstraint(resolveConstraint(node, calledMethod, constraint));
|
edge.setConstraint(resolveConstraint(node, qualifiedTarget, constraint));
|
||||||
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
|
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
|
||||||
}
|
}
|
||||||
for (Object argObj : node.arguments()) {
|
for (Object argObj : node.arguments()) {
|
||||||
@@ -3742,11 +3778,31 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
List<String> path,
|
List<String> path,
|
||||||
Map<String, List<CallEdge>> callGraph,
|
Map<String, List<CallEdge>> callGraph,
|
||||||
TriggerPoint tp) {
|
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;
|
return polymorphicEvents;
|
||||||
}
|
}
|
||||||
Map<String, String> bindings = resolvePathBindings(path, callGraph, Map.of());
|
Map<String, String> bindings = resolvePathBindings(path, callGraph, Map.of());
|
||||||
if (bindings.isEmpty()) {
|
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)
|
if (hasMultiClassPolymorphicPath(path)
|
||||||
&& polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) {
|
&& polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) {
|
||||||
List<String> callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path);
|
List<String> callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path);
|
||||||
@@ -3876,7 +3932,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
if (candidateExpr == null) {
|
if (candidateExpr == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (candidateExpr.toString().contains(".toUpperCase()")) {
|
if (EnumExpansionHintSupport.expressionHasHint(candidateExpr)) {
|
||||||
expansionHint[0] = true;
|
expansionHint[0] = true;
|
||||||
}
|
}
|
||||||
if (candidateExpr instanceof MethodInvocation localMethod
|
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(".")) {
|
if (resolvedPathMethod != null && resolvedPathMethod.contains(".")) {
|
||||||
String ownerFqn = resolvedPathMethod.substring(0, resolvedPathMethod.lastIndexOf('.'));
|
String ownerFqn = resolvedPathMethod.substring(0, resolvedPathMethod.lastIndexOf('.'));
|
||||||
String methodName = resolvedPathMethod.substring(resolvedPathMethod.lastIndexOf('.') + 1);
|
String methodName = resolvedPathMethod.substring(resolvedPathMethod.lastIndexOf('.') + 1);
|
||||||
@@ -3903,7 +3964,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
MethodDeclaration pathMethodDeclaration = pathOwnerType != null
|
MethodDeclaration pathMethodDeclaration = pathOwnerType != null
|
||||||
? context.findMethodDeclaration(pathOwnerType, methodName, true)
|
? context.findMethodDeclaration(pathOwnerType, methodName, true)
|
||||||
: null;
|
: null;
|
||||||
if (hasEnumExpansionHint(pathMethodDeclaration)) {
|
if (EnumExpansionHintSupport.methodBodyHasHint(pathMethodDeclaration)) {
|
||||||
expansionHint[0] = true;
|
expansionHint[0] = true;
|
||||||
String hintedEnumType = inferEnumTypeFromMethod(pathMethodDeclaration);
|
String hintedEnumType = inferEnumTypeFromMethod(pathMethodDeclaration);
|
||||||
if (hintedEnumType != null) {
|
if (hintedEnumType != null) {
|
||||||
@@ -3952,9 +4013,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
if ((expansionHint[0] || hasMultiClassPolymorphicPath(path))
|
if ((expansionHint[0] || hasMultiClassPolymorphicPath(path))
|
||||||
&& distinct.stream().noneMatch(AbstractCallGraphEngine.this::looksLikeEnumConstant)) {
|
&& distinct.stream().noneMatch(AbstractCallGraphEngine.this::looksLikeEnumConstant)) {
|
||||||
List<String> expanded = expandSingleSymbolicType(distinct);
|
List<String> expanded = expandSingleSymbolicType(distinct);
|
||||||
if (expanded.isEmpty() && hasMultiClassPolymorphicPath(path)) {
|
if (expanded.isEmpty() && (expansionHint[0] || hasMultiClassPolymorphicPath(path))) {
|
||||||
expanded = inferEnumValuesFromPath(path);
|
expanded = inferEnumValuesFromPath(path);
|
||||||
}
|
}
|
||||||
|
if (expanded.isEmpty() && expansionHint[0]) {
|
||||||
|
String enumType = inferEnumTypeFromMethod(callerDeclaration);
|
||||||
|
if (enumType != null) {
|
||||||
|
expanded = expandDeclaredEnumValues(enumType);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!expanded.isEmpty()) {
|
if (!expanded.isEmpty()) {
|
||||||
return expanded;
|
return expanded;
|
||||||
}
|
}
|
||||||
@@ -3962,6 +4029,85 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
return distinct;
|
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) {
|
private List<String> inferEnumValuesFromPath(List<String> path) {
|
||||||
if (path == null || path.isEmpty()) {
|
if (path == null || path.isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
@@ -4049,25 +4195,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
return null;
|
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) {
|
private String findPathMethodByName(List<String> path, String methodName) {
|
||||||
if (path == null || methodName == null) {
|
if (path == null || methodName == null) {
|
||||||
return 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.GetterBodyConstantScanner;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
|
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.analysis.resolver.ConstantResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.eclipse.jdt.core.dom.*;
|
import org.eclipse.jdt.core.dom.*;
|
||||||
@@ -214,11 +216,15 @@ public class ConstantExtractor {
|
|||||||
if (expr == null || resolved == null) {
|
if (expr == null || resolved == null) {
|
||||||
return false;
|
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(".*>"))) {
|
if (!(resolved.startsWith("<SYMBOLIC:") || resolved.contains("SYMBOLIC:") || resolved.endsWith(".*>"))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return expr instanceof SwitchExpression
|
return expr instanceof ConditionalExpression;
|
||||||
|| expr instanceof ConditionalExpression;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void extractConstantsFromArgument(Expression argObj, List<String> constants) {
|
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) {
|
if (!handled && variableTracer != null && constructorAnalyzer != null) {
|
||||||
List<Expression> tracedRetExprs = variableTracer.traceVariableAll(retExpr);
|
List<Expression> tracedRetExprs = variableTracer.traceVariableAll(retExpr);
|
||||||
@@ -569,25 +594,12 @@ public class ConstantExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void extractConstantsFromSwitchLike(ASTNode switchNode, List<String> constants) {
|
private void extractConstantsFromSwitchLike(ASTNode switchNode, List<String> constants) {
|
||||||
switchNode.accept(new ASTVisitor() {
|
if (SwitchConstSupport.hasOpaqueArm(switchNode)) {
|
||||||
@Override
|
return;
|
||||||
public boolean visit(YieldStatement ys) {
|
}
|
||||||
extractConstantsFromExpression(ys.getExpression(), constants);
|
for (Expression armExpr : SwitchConstSupport.armPayloadExpressions(switchNode)) {
|
||||||
return super.visit(ys);
|
extractConstantsFromExpression(armExpr, constants);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean visit(ExpressionStatement es) {
|
|
||||||
extractConstantsFromExpression(es.getExpression(), constants);
|
|
||||||
return super.visit(es);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean visit(ReturnStatement rs) {
|
|
||||||
extractConstantsFromExpression(rs.getExpression(), constants);
|
|
||||||
return super.visit(rs);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isKeyedLookupMethod(MethodInvocation mi) {
|
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.LibraryHint;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
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.pipeline.ReactiveExpressionSupport;
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
@@ -879,6 +880,22 @@ public class GenericEventDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String extractEventFromMessageBuilder(Expression expr) {
|
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) {
|
if (expr instanceof CastExpression ce) {
|
||||||
return extractEventFromMessageBuilder(ce.getExpression());
|
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.BooleanConstraintEvaluator;
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.TruncatedEnumRefReconstructor;
|
import click.kamil.springstatemachineexporter.analysis.resolver.TruncatedEnumRefReconstructor;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -216,6 +217,9 @@ public class PathBindingEvaluator {
|
|||||||
if (resolved == null || resolved.isBlank() || resolved.equals(paramName)) {
|
if (resolved == null || resolved.isBlank() || resolved.equals(paramName)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (AstUtils.isAnalysisToken(resolved) || !AstUtils.isConcreteConstant(resolved)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (BooleanConstraintEvaluator.isIncompleteDottedName(resolved)) {
|
if (BooleanConstraintEvaluator.isIncompleteDottedName(resolved)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -224,7 +228,7 @@ public class PathBindingEvaluator {
|
|||||||
if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(candidate)) {
|
if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(candidate)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (candidate.contains("(") && !isEnumLikeConstant(candidate)) {
|
if (candidate.contains("(") && !AstUtils.isEnumLikeConstantName(candidate)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -246,19 +250,33 @@ public class PathBindingEvaluator {
|
|||||||
if (rawValue == null || rawValue.isBlank()) {
|
if (rawValue == null || rawValue.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (rawValue.startsWith("\"") || isEnumLikeConstant(rawValue)) {
|
if (AstUtils.isAnalysisToken(rawValue)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (rawValue.startsWith("\"") || AstUtils.isEnumLikeConstantName(rawValue)) {
|
||||||
return maybeReconstruct(normalizeLiteral(rawValue));
|
return maybeReconstruct(normalizeLiteral(rawValue));
|
||||||
}
|
}
|
||||||
if (bindings.containsKey(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);
|
String traced = variableTracer.traceLocalVariable(callerFqn, rawValue, bindings);
|
||||||
if (traced != null && !traced.isBlank()) {
|
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);
|
return maybeReconstruct(traced);
|
||||||
}
|
}
|
||||||
String fromCall = expressionResolver.resolveMethodCallFromSource(callerFqn, rawValue, bindings);
|
String fromCall = expressionResolver.resolveMethodCallFromSource(callerFqn, rawValue, bindings);
|
||||||
if (fromCall != null) {
|
if (fromCall != null && !AstUtils.isAnalysisToken(fromCall)) {
|
||||||
return maybeReconstruct(fromCall);
|
return maybeReconstruct(fromCall);
|
||||||
}
|
}
|
||||||
if (!traced.equals(rawValue)) {
|
if (!traced.equals(rawValue)) {
|
||||||
@@ -282,17 +300,6 @@ public class PathBindingEvaluator {
|
|||||||
return value;
|
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(
|
private static CallEdge findEdge(
|
||||||
String caller, String target, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
|
String caller, String target, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
|
||||||
List<CallEdge> edges = callGraph.get(caller);
|
List<CallEdge> edges = callGraph.get(caller);
|
||||||
|
|||||||
@@ -21,14 +21,24 @@ public class PathBindingExpressionResolver {
|
|||||||
private final CodebaseContext context;
|
private final CodebaseContext context;
|
||||||
private final VariableTracer variableTracer;
|
private final VariableTracer variableTracer;
|
||||||
private final ConstantResolver constantResolver;
|
private final ConstantResolver constantResolver;
|
||||||
|
private final TypeResolver typeResolver;
|
||||||
|
|
||||||
public PathBindingExpressionResolver(
|
public PathBindingExpressionResolver(
|
||||||
CodebaseContext context,
|
CodebaseContext context,
|
||||||
VariableTracer variableTracer,
|
VariableTracer variableTracer,
|
||||||
ConstantResolver constantResolver) {
|
ConstantResolver constantResolver) {
|
||||||
|
this(context, variableTracer, constantResolver, new TypeResolver(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
PathBindingExpressionResolver(
|
||||||
|
CodebaseContext context,
|
||||||
|
VariableTracer variableTracer,
|
||||||
|
ConstantResolver constantResolver,
|
||||||
|
TypeResolver typeResolver) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.variableTracer = variableTracer;
|
this.variableTracer = variableTracer;
|
||||||
this.constantResolver = constantResolver;
|
this.constantResolver = constantResolver;
|
||||||
|
this.typeResolver = typeResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String resolveMethodCallFromSource(String callerFqn, String varName, Map<String, String> bindings) {
|
public String resolveMethodCallFromSource(String callerFqn, String varName, Map<String, String> bindings) {
|
||||||
@@ -111,7 +121,11 @@ public class PathBindingExpressionResolver {
|
|||||||
|
|
||||||
context.setAnalysisNamedBindings(callBindings);
|
context.setAnalysisNamedBindings(callBindings);
|
||||||
try {
|
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 {
|
} finally {
|
||||||
context.setAnalysisNamedBindings(bindings);
|
context.setAnalysisNamedBindings(bindings);
|
||||||
}
|
}
|
||||||
@@ -139,10 +153,7 @@ public class PathBindingExpressionResolver {
|
|||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (value.startsWith("ENUM_SET:")
|
if (AstUtils.isAnalysisToken(value)) {
|
||||||
|| value.startsWith("<SYMBOLIC:")
|
|
||||||
|| value.contains("SYMBOLIC:")
|
|
||||||
|| value.endsWith(".*>")) {
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (value.startsWith("\"") || AstUtils.isEnumLikeConstantName(value)) {
|
if (value.startsWith("\"") || AstUtils.isEnumLikeConstantName(value)) {
|
||||||
@@ -179,27 +190,28 @@ public class PathBindingExpressionResolver {
|
|||||||
if (td == null) {
|
if (td == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
CompilationUnit cu = td.getRoot() instanceof CompilationUnit c ? c : null;
|
||||||
for (FieldDeclaration field : td.getFields()) {
|
for (FieldDeclaration field : td.getFields()) {
|
||||||
for (Object fragmentObj : field.fragments()) {
|
for (Object fragmentObj : field.fragments()) {
|
||||||
VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj;
|
VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj;
|
||||||
if (fragment.getName().getIdentifier().equals(fieldName)) {
|
if (fragment.getName().getIdentifier().equals(fieldName)) {
|
||||||
IVariableBinding binding = fragment.resolveBinding();
|
IVariableBinding binding = fragment.resolveBinding();
|
||||||
if (binding != null && binding.getType() != null) {
|
if (binding != null && binding.getType() != null && !binding.getType().isRecovered()) {
|
||||||
return binding.getType().getErasure().getQualifiedName();
|
String fqn = binding.getType().getErasure().getQualifiedName();
|
||||||
|
if (fqn != null && fqn.contains(".") && !fqn.startsWith("<")) {
|
||||||
|
return fqn;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
String simpleType = field.getType().toString();
|
String viaTr = typeResolver.resolveTypeToFqn(field.getType(), cu);
|
||||||
TypeDeclaration resolved = context.getTypeDeclaration(simpleType);
|
if (viaTr != null && viaTr.contains(".")) {
|
||||||
|
return viaTr;
|
||||||
|
}
|
||||||
|
AbstractTypeDeclaration resolved =
|
||||||
|
context.getAbstractTypeDeclaration(field.getType().toString(), cu);
|
||||||
if (resolved != null) {
|
if (resolved != null) {
|
||||||
return context.getFqn(resolved);
|
return context.getFqn(resolved);
|
||||||
}
|
}
|
||||||
if (td.getRoot() instanceof CompilationUnit cu && cu.getPackage() != null) {
|
return field.getType().toString();
|
||||||
String pkg = cu.getPackage().getName().getFullyQualifiedName();
|
|
||||||
resolved = context.getTypeDeclaration(pkg + "." + simpleType);
|
|
||||||
if (resolved != null) {
|
|
||||||
return context.getFqn(resolved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return simpleType;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,21 @@ public class VariableTracer {
|
|||||||
List<String> stringified = new ArrayList<>();
|
List<String> stringified = new ArrayList<>();
|
||||||
for (BranchAssignment assignment : filtered) {
|
for (BranchAssignment assignment : filtered) {
|
||||||
Expression expr = assignment.expression();
|
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);
|
Expression traced = traceVariable(expr);
|
||||||
if (traced instanceof MethodInvocation mi) {
|
if (traced instanceof MethodInvocation mi) {
|
||||||
Expression innerMost = unwrapMethodInvocation(mi, 0, methodFqn);
|
Expression innerMost = unwrapMethodInvocation(mi, 0, methodFqn);
|
||||||
@@ -408,6 +423,126 @@ public class VariableTracer {
|
|||||||
return null;
|
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) {
|
private String evaluateSwitchAssignment(String methodFqn, String varName, Map<String, String> parameterValues) {
|
||||||
TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
|
TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
|
||||||
if (td == null) {
|
if (td == null) {
|
||||||
@@ -423,8 +558,8 @@ public class VariableTracer {
|
|||||||
@Override
|
@Override
|
||||||
public boolean visit(VariableDeclarationFragment node) {
|
public boolean visit(VariableDeclarationFragment node) {
|
||||||
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
|
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
|
||||||
Expression init = node.getInitializer();
|
Expression init = peelExpression(node.getInitializer());
|
||||||
if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION || init.getNodeType() == ASTNode.SWITCH_STATEMENT) {
|
if (init instanceof SwitchExpression) {
|
||||||
switchNode[0] = init;
|
switchNode[0] = init;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -435,8 +570,8 @@ public class VariableTracer {
|
|||||||
@Override
|
@Override
|
||||||
public boolean visit(Assignment node) {
|
public boolean visit(Assignment node) {
|
||||||
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
|
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
|
||||||
Expression rhs = node.getRightHandSide();
|
Expression rhs = peelExpression(node.getRightHandSide());
|
||||||
if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION || rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
|
if (rhs instanceof SwitchExpression) {
|
||||||
switchNode[0] = rhs;
|
switchNode[0] = rhs;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -447,9 +582,6 @@ public class VariableTracer {
|
|||||||
if (switchNode[0] instanceof SwitchExpression se) {
|
if (switchNode[0] instanceof SwitchExpression se) {
|
||||||
return constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
|
return constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
|
||||||
}
|
}
|
||||||
if (switchNode[0] instanceof SwitchStatement ss) {
|
|
||||||
return constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
|
|
||||||
}
|
|
||||||
return null;
|
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.Type;
|
||||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -404,10 +406,62 @@ public final class AstUtils {
|
|||||||
return "\"" + boundValue.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
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()) {
|
if (value == null || value.isEmpty()) {
|
||||||
return false;
|
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('.');
|
int dot = value.lastIndexOf('.');
|
||||||
if (dot <= 0 || dot >= value.length() - 1) {
|
if (dot <= 0 || dot >= value.length() - 1) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -96,6 +96,44 @@ class InstanceofNarrowerTest {
|
|||||||
assertThat(refined.uniqueOrNull().typeFqn()).contains("PayRichEvent");
|
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
|
@Test
|
||||||
void refineConcreteConflictFailsClosed(@TempDir Path tempDir) throws Exception {
|
void refineConcreteConflictFailsClosed(@TempDir Path tempDir) throws Exception {
|
||||||
Fixture f = scan(tempDir, """
|
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");
|
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
|
@Test
|
||||||
void prefersAllowlistedHeaderOverPayload(@TempDir Path tempDir) throws Exception {
|
void prefersAllowlistedHeaderOverPayload(@TempDir Path tempDir) throws Exception {
|
||||||
Expression sendArg = sendArg(tempDir, """
|
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 {
|
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
|
@Test
|
||||||
void shouldAcceptMatchingEnumSwitchConstraint() {
|
void shouldAcceptMatchingEnumSwitchConstraint() {
|
||||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
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");
|
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 {
|
private static void assertFailClosed(String source, Path tempDir, String entryMethod) throws Exception {
|
||||||
CodebaseContext context = scanSource(source, tempDir);
|
CodebaseContext context = scanSource(source, tempDir);
|
||||||
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
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