F
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.flow.eventid;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||
|
||||
/**
|
||||
* Format-safe identity for event constants that may appear bare ({@code PAY}) or
|
||||
* qualified ({@code OrderEvent.PAY}). Used at compare/merge boundaries only —
|
||||
* {@code resolveUnique} may still return bare carriers for PathBinding callers.
|
||||
*/
|
||||
public final class EventIdentityNormalize {
|
||||
|
||||
private EventIdentityNormalize() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Last segment of a dotted constant, or the whole string if bare.
|
||||
* Analysis tokens yield {@code null}.
|
||||
*/
|
||||
public static String simpleName(String value) {
|
||||
if (value == null || value.isBlank() || AstUtils.isAnalysisToken(value)) {
|
||||
return null;
|
||||
}
|
||||
int dot = value.lastIndexOf('.');
|
||||
if (dot < 0 || dot >= value.length() - 1) {
|
||||
return value;
|
||||
}
|
||||
// Prefer EnumType.MEMBER stripping (last two segments → MEMBER is wrong;
|
||||
// use last segment only for identity).
|
||||
return value.substring(dot + 1);
|
||||
}
|
||||
|
||||
/** True when both name the same enum member (bare or qualified). */
|
||||
public static boolean sameConstant(String a, String b) {
|
||||
String sa = simpleName(a);
|
||||
String sb = simpleName(b);
|
||||
return sa != null && sa.equals(sb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer {@code EnumType.MEMBER} when either side is already qualified or
|
||||
* {@code enumType} is known; otherwise keep the first non-blank.
|
||||
*/
|
||||
public static String preferQualified(String existing, String candidate, String enumType) {
|
||||
String a = existing == null || existing.isBlank() ? null : existing;
|
||||
String b = candidate == null || candidate.isBlank() ? null : candidate;
|
||||
if (a == null) {
|
||||
return qualifyIfPossible(b, enumType);
|
||||
}
|
||||
if (b == null) {
|
||||
return qualifyIfPossible(a, enumType);
|
||||
}
|
||||
if (!sameConstant(a, b)) {
|
||||
return a;
|
||||
}
|
||||
String preferred = moreQualified(a, b);
|
||||
return qualifyIfPossible(preferred, enumType);
|
||||
}
|
||||
|
||||
private static String moreQualified(String a, String b) {
|
||||
boolean aQ = a.contains(".");
|
||||
boolean bQ = b.contains(".");
|
||||
if (aQ && !bQ) {
|
||||
return a;
|
||||
}
|
||||
if (bQ && !aQ) {
|
||||
return b;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
private static String qualifyIfPossible(String value, String enumType) {
|
||||
if (value == null || value.isBlank() || AstUtils.isAnalysisToken(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value.contains(".") || enumType == null || enumType.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
String simple = simpleName(value);
|
||||
if (simple == null || !simple.matches("[A-Z_][A-Z0-9_]*")) {
|
||||
return value;
|
||||
}
|
||||
String typeSimple = enumType.contains(".")
|
||||
? enumType.substring(enumType.lastIndexOf('.') + 1)
|
||||
: enumType;
|
||||
return typeSimple + "." + simple;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.flow.eventid;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Shared poly-publish helpers so ACE (and future publishers) do not reinvent
|
||||
* UNIQUE-vs-ENUM_EXPAND adopt rules.
|
||||
*/
|
||||
public final class EventIdentityPolyPublish {
|
||||
|
||||
private EventIdentityPolyPublish() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable copy of expand members when the consult is adoptable as poly widen;
|
||||
* otherwise empty list (never UNIQUE proofs).
|
||||
*/
|
||||
public static List<String> adoptExpandOrEmpty(ExpandConsult consult) {
|
||||
if (consult == null || !consult.adoptableAsPolyExpand()) {
|
||||
return List.of();
|
||||
}
|
||||
return new ArrayList<>(consult.members());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import click.kamil.springstatemachineexporter.analysis.flow.alloc.InstanceofNarr
|
||||
import click.kamil.springstatemachineexporter.analysis.flow.alloc.ReceiverFacts;
|
||||
import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.EventArgumentSelector;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.IdentityStringTransformSupport;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.MessageCarrierSupport;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
|
||||
@@ -85,10 +87,6 @@ public final class EventIdentityResolver {
|
||||
"sendEventMono",
|
||||
"trigger");
|
||||
|
||||
/** Identity-preserving String instance methods (0-arg only). */
|
||||
private static final Set<String> IDENTITY_STRING_PEELS = Set.of(
|
||||
"trim", "strip", "toUpperCase", "toLowerCase");
|
||||
|
||||
/** Compact 0-arg identity accessor names; still gated by unique compile-time body constant. */
|
||||
private static final Set<String> COMPACT_ACCESSOR_NAMES = Set.of(
|
||||
"type", "event", "code", "a", "id");
|
||||
@@ -129,6 +127,57 @@ public final class EventIdentityResolver {
|
||||
return resolveUnique(expression, methodPath, callGraph, 0, new LinkedHashSet<>());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hint-gated multi-member expand for unbound dynamic strings.
|
||||
* Thin wrapper over {@link #resolveExpandConsult}; returns concrete members only.
|
||||
*/
|
||||
public List<String> resolveExpanded(
|
||||
Expression expression,
|
||||
List<String> methodPath,
|
||||
Map<String, List<CallEdge>> callGraph) {
|
||||
return resolveExpandConsult(expression, methodPath, callGraph).members();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared unique-or-expand consult for ACE and future poly publishers.
|
||||
* <ol>
|
||||
* <li>If {@link #resolveUnique} proves one constant → {@link ExpandConsult.Source#UNIQUE}.</li>
|
||||
* <li>Else if expr-local or helper-return expand hint licenses an enum type →
|
||||
* {@link ExpandConsult.Source#ENUM_EXPAND} with all concrete members.</li>
|
||||
* <li>Else {@link ExpandConsult.Source#EMPTY}. Never returns analysis tokens.</li>
|
||||
* </ol>
|
||||
* Poly publishers must use {@link ExpandConsult#adoptableAsPolyExpand()} (or
|
||||
* {@link EventIdentityPolyPublish#adoptExpandOrEmpty}) — never publish UNIQUE from consult.
|
||||
*/
|
||||
public ExpandConsult resolveExpandConsult(
|
||||
Expression expression,
|
||||
List<String> methodPath,
|
||||
Map<String, List<CallEdge>> callGraph) {
|
||||
if (expression == null) {
|
||||
return ExpandConsult.empty();
|
||||
}
|
||||
List<String> unique = resolveUnique(expression, methodPath, callGraph);
|
||||
if (unique.size() == 1) {
|
||||
String enumType = inferExpandEnumType(expression);
|
||||
List<String> qualified = qualifyMembers(unique, enumType);
|
||||
return ExpandConsult.unique(qualified, enumType);
|
||||
}
|
||||
if (expressionHasExpandHintDeep(expression)) {
|
||||
String enumType = inferExpandEnumType(expression);
|
||||
if (enumType != null && !enumType.isBlank()) {
|
||||
List<String> members = expandDeclaredEnumMembers(enumType);
|
||||
if (!members.isEmpty()) {
|
||||
return ExpandConsult.enumExpand(members, enumType);
|
||||
}
|
||||
}
|
||||
}
|
||||
ExpandConsult fromHelper = expandViaHelperPath(expression, methodPath);
|
||||
if (!fromHelper.isEmpty()) {
|
||||
return fromHelper;
|
||||
}
|
||||
return ExpandConsult.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Peel expression to an {@link AccessProof} when a virtual accessor + receiver facts are proven.
|
||||
*/
|
||||
@@ -187,9 +236,8 @@ public final class EventIdentityResolver {
|
||||
preferred.add(arg);
|
||||
return super.visit(node);
|
||||
}
|
||||
if (isSendLikeName(name) && node.arguments().size() == 1
|
||||
&& node.arguments().get(0) instanceof Expression arg) {
|
||||
preferred.add(arg);
|
||||
if (isSendLikeName(name) && !node.arguments().isEmpty()) {
|
||||
EventArgumentSelector.selectEventArgument(node).ifPresent(preferred::add);
|
||||
return super.visit(node);
|
||||
}
|
||||
if (node.arguments().size() == 1 && looksLikeValueOfHelper(node)) {
|
||||
@@ -213,22 +261,35 @@ public final class EventIdentityResolver {
|
||||
/**
|
||||
* Resolve preferred exprs; succeed only when every unique proof agrees on the same constant.
|
||||
* Multiple different unique proofs → empty (fail-closed, no first-wins).
|
||||
* Bare {@code PAY} and qualified {@code OrderEvent.PAY} count as the same proof.
|
||||
*/
|
||||
private List<String> resolveUniqueAgreeing(
|
||||
List<Expression> exprs,
|
||||
List<String> methodPath,
|
||||
Map<String, List<CallEdge>> callGraph) {
|
||||
LinkedHashSet<String> proven = new LinkedHashSet<>();
|
||||
// simpleName → preferred (qualified) form
|
||||
java.util.LinkedHashMap<String, String> proven = new java.util.LinkedHashMap<>();
|
||||
for (Expression expr : exprs) {
|
||||
List<String> unique = resolveUnique(expr, methodPath, callGraph);
|
||||
if (unique.size() == 1) {
|
||||
proven.add(unique.get(0));
|
||||
if (proven.size() > 1) {
|
||||
return List.of();
|
||||
}
|
||||
if (unique.size() != 1) {
|
||||
continue;
|
||||
}
|
||||
String constant = unique.get(0);
|
||||
String simple = EventIdentityNormalize.simpleName(constant);
|
||||
if (simple == null) {
|
||||
continue;
|
||||
}
|
||||
String existing = proven.get(simple);
|
||||
if (existing == null) {
|
||||
proven.put(simple, constant);
|
||||
} else {
|
||||
proven.put(simple, EventIdentityNormalize.preferQualified(existing, constant, null));
|
||||
}
|
||||
if (proven.size() > 1) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
return proven.size() == 1 ? List.copyOf(proven) : List.of();
|
||||
return proven.size() == 1 ? List.copyOf(proven.values()) : List.of();
|
||||
}
|
||||
|
||||
private List<String> resolveUnique(
|
||||
@@ -1301,21 +1362,19 @@ public final class EventIdentityResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Peel identity-preserving String ops: trim/strip/toUpperCase/toLowerCase with <b>0 args</b>.
|
||||
* Also peels Optional.of / get / orElseThrow (0-arg) wrappers when the receiver is clearly
|
||||
* an Optional/Mono/Flux factory chain — not {@code Supplier.get()}.
|
||||
* Locale/{@code replace} stay intact (fail-closed).
|
||||
* Peel identity-preserving String ops via {@link IdentityStringTransformSupport}
|
||||
* (0-arg trim/strip/toUpperCase/toLowerCase). Also peels Optional.of / get / orElseThrow
|
||||
* (0-arg) wrappers when the receiver is clearly an Optional/Mono/Flux factory chain —
|
||||
* not {@code Supplier.get()}. Locale/{@code replace} stay intact (fail-closed).
|
||||
*/
|
||||
private static Expression peelIdentityString(Expression expression) {
|
||||
Expression current = peel(expression);
|
||||
boolean progressed;
|
||||
do {
|
||||
progressed = false;
|
||||
if (current instanceof MethodInvocation mi
|
||||
&& mi.getExpression() != null
|
||||
&& mi.arguments().isEmpty()
|
||||
&& IDENTITY_STRING_PEELS.contains(mi.getName().getIdentifier())) {
|
||||
current = peel(mi.getExpression());
|
||||
Expression afterStringOps = IdentityStringTransformSupport.peelIdentityStringOps(current);
|
||||
if (afterStringOps != current) {
|
||||
current = peel(afterStringOps);
|
||||
progressed = true;
|
||||
continue;
|
||||
}
|
||||
@@ -1778,6 +1837,403 @@ public final class EventIdentityResolver {
|
||||
return System.identityHashCode(expression) + "@" + pathKey + "#" + depth;
|
||||
}
|
||||
|
||||
private boolean expressionHasExpandHintDeep(Expression expression) {
|
||||
if (expression == null) {
|
||||
return false;
|
||||
}
|
||||
if (IdentityStringTransformSupport.expressionHasExpandHint(expression)) {
|
||||
return true;
|
||||
}
|
||||
Expression peeled = peel(expression);
|
||||
if (peeled instanceof MethodInvocation valueOfMi
|
||||
&& "valueOf".equals(valueOfMi.getName().getIdentifier())
|
||||
&& !valueOfMi.arguments().isEmpty()) {
|
||||
Expression arg = (Expression) valueOfMi.arguments().get(valueOfMi.arguments().size() - 1);
|
||||
if (IdentityStringTransformSupport.expressionHasExpandHint(arg)) {
|
||||
return true;
|
||||
}
|
||||
if (arg instanceof SimpleName && variableTracer != null) {
|
||||
for (Expression def : variableTracer.traceVariableAll(arg)) {
|
||||
if (IdentityStringTransformSupport.expressionHasExpandHint(def)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (peeled instanceof SimpleName && variableTracer != null) {
|
||||
for (Expression def : variableTracer.traceVariableAll(peeled)) {
|
||||
if (IdentityStringTransformSupport.expressionHasExpandHint(def)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String inferExpandEnumType(Expression expression) {
|
||||
Expression peeled = peel(expression);
|
||||
if (peeled instanceof MethodInvocation valueOfMi
|
||||
&& "valueOf".equals(valueOfMi.getName().getIdentifier())) {
|
||||
Expression receiver = valueOfMi.getExpression();
|
||||
if (receiver != null) {
|
||||
String fromRecv = typeNameOf(receiver);
|
||||
if (fromRecv != null) {
|
||||
return fromRecv;
|
||||
}
|
||||
}
|
||||
if (valueOfMi.arguments().size() == 2
|
||||
&& valueOfMi.arguments().get(0) instanceof Expression classArg) {
|
||||
String fromClass = typeNameOf(classArg);
|
||||
if (fromClass != null) {
|
||||
return fromClass;
|
||||
}
|
||||
}
|
||||
ITypeBinding binding = valueOfMi.resolveTypeBinding();
|
||||
if (binding != null && binding.isEnum()) {
|
||||
return binding.getQualifiedName();
|
||||
}
|
||||
}
|
||||
ITypeBinding binding = peeled == null ? null : peeled.resolveTypeBinding();
|
||||
if (binding != null && binding.isEnum()) {
|
||||
return binding.getQualifiedName();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String typeNameOf(Expression expression) {
|
||||
if (expression == null) {
|
||||
return null;
|
||||
}
|
||||
Expression current = expression;
|
||||
while (current instanceof ParenthesizedExpression pe) {
|
||||
current = pe.getExpression();
|
||||
}
|
||||
if (current instanceof SimpleName sn) {
|
||||
return sn.getIdentifier();
|
||||
}
|
||||
if (current instanceof QualifiedName qn) {
|
||||
return qn.getFullyQualifiedName();
|
||||
}
|
||||
if (current instanceof Name name) {
|
||||
return name.getFullyQualifiedName();
|
||||
}
|
||||
ITypeBinding binding = current.resolveTypeBinding();
|
||||
if (binding != null) {
|
||||
String qn = binding.getQualifiedName();
|
||||
if (qn != null && !qn.isBlank()) {
|
||||
return qn;
|
||||
}
|
||||
return binding.getName();
|
||||
}
|
||||
return current.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Path-owned / same-class helper returns with expand hints (e.g. {@code valueOf(x).name()}).
|
||||
*/
|
||||
private ExpandConsult expandViaHelperPath(Expression expression, List<String> methodPath) {
|
||||
if (expression == null || methodPath == null || methodPath.isEmpty()) {
|
||||
return ExpandConsult.empty();
|
||||
}
|
||||
MethodDeclaration enclosing = findEnclosingMethod(expression);
|
||||
String enclosingClass = enclosingClassFqn(enclosing);
|
||||
List<Expression> candidates = new ArrayList<>();
|
||||
collectExpandCandidates(expression, candidates);
|
||||
for (Expression candidate : candidates) {
|
||||
if (!(candidate instanceof MethodInvocation localMethod)) {
|
||||
continue;
|
||||
}
|
||||
if (localMethod.getExpression() != null
|
||||
&& !(localMethod.getExpression() instanceof ThisExpression)) {
|
||||
continue;
|
||||
}
|
||||
String helperName = localMethod.getName().getIdentifier();
|
||||
MethodDeclaration helper = resolvePathHelper(methodPath, enclosingClass, helperName);
|
||||
if (helper == null) {
|
||||
continue;
|
||||
}
|
||||
if (!IdentityStringTransformSupport.returnExpressionsHaveExpandHint(helper)) {
|
||||
continue;
|
||||
}
|
||||
String enumType = inferEnumTypeFromMethod(helper);
|
||||
if (enumType == null || enumType.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
List<String> members = expandDeclaredEnumMembers(enumType);
|
||||
if (!members.isEmpty()) {
|
||||
return ExpandConsult.enumExpand(members, enumType);
|
||||
}
|
||||
}
|
||||
return ExpandConsult.empty();
|
||||
}
|
||||
|
||||
private void collectExpandCandidates(Expression expression, List<Expression> out) {
|
||||
if (expression == null) {
|
||||
return;
|
||||
}
|
||||
Expression peeled = peel(expression);
|
||||
if (peeled instanceof SimpleName sn) {
|
||||
if (variableTracer != null) {
|
||||
for (Expression def : variableTracer.traceVariableAll(peeled)) {
|
||||
if (def != null) {
|
||||
out.add(def);
|
||||
Expression defPeeled = peel(def);
|
||||
if (defPeeled != null && defPeeled != def) {
|
||||
out.add(defPeeled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MethodDeclaration enclosing = findEnclosingMethod(peeled);
|
||||
Expression init = findLocalInitializer(enclosing, sn.getIdentifier());
|
||||
if (init != null) {
|
||||
out.add(init);
|
||||
Expression initPeeled = peel(init);
|
||||
if (initPeeled != null && initPeeled != init) {
|
||||
out.add(initPeeled);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (peeled instanceof MethodInvocation valueOfMi
|
||||
&& "valueOf".equals(valueOfMi.getName().getIdentifier())
|
||||
&& !valueOfMi.arguments().isEmpty()) {
|
||||
Expression arg = (Expression) valueOfMi.arguments().get(valueOfMi.arguments().size() - 1);
|
||||
collectExpandCandidates(arg, out);
|
||||
}
|
||||
if (peeled != null) {
|
||||
out.add(peeled);
|
||||
}
|
||||
out.add(expression);
|
||||
}
|
||||
|
||||
private MethodDeclaration resolvePathHelper(
|
||||
List<String> methodPath,
|
||||
String enclosingClass,
|
||||
String helperName) {
|
||||
if (helperName == null || helperName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
// Prefer concrete path owners with a hinted method *body* (subclass overrides)
|
||||
// over abstract declaring types from includeSuper lookups.
|
||||
if (methodPath != null) {
|
||||
for (int i = methodPath.size() - 1; i >= 0; i--) {
|
||||
String methodFqn = methodPath.get(i);
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
continue;
|
||||
}
|
||||
String ownerFqn = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
MethodDeclaration hinted = findHintedHelperOnOwner(ownerFqn, helperName);
|
||||
if (hinted != null) {
|
||||
return hinted;
|
||||
}
|
||||
}
|
||||
}
|
||||
String pathMethod = findPathMethodByName(methodPath, helperName);
|
||||
if (pathMethod != null && pathMethod.contains(".")) {
|
||||
String ownerFqn = pathMethod.substring(0, pathMethod.lastIndexOf('.'));
|
||||
String methodName = pathMethod.substring(pathMethod.lastIndexOf('.') + 1);
|
||||
MethodDeclaration hinted = findHintedHelperOnOwner(ownerFqn, methodName);
|
||||
if (hinted != null) {
|
||||
return hinted;
|
||||
}
|
||||
}
|
||||
if (enclosingClass != null) {
|
||||
String fallbackOwner = findPrecedingConcretePathClass(methodPath, enclosingClass);
|
||||
if (fallbackOwner != null) {
|
||||
MethodDeclaration hinted = findHintedHelperOnOwner(fallbackOwner, helperName);
|
||||
if (hinted != null) {
|
||||
return hinted;
|
||||
}
|
||||
}
|
||||
MethodDeclaration local = findHintedHelperOnOwner(enclosingClass, helperName);
|
||||
if (local != null) {
|
||||
return local;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Own-type method first (includeSuper=false), then inherited — but only if the
|
||||
* declaration has a body with an expand hint (skips abstract stubs).
|
||||
*/
|
||||
private MethodDeclaration findHintedHelperOnOwner(String ownerFqn, String helperName) {
|
||||
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
|
||||
if (ownerType == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration own = context.findMethodDeclaration(ownerType, helperName, false);
|
||||
if (isHintedHelperBody(own)) {
|
||||
return own;
|
||||
}
|
||||
MethodDeclaration inherited = context.findMethodDeclaration(ownerType, helperName, true);
|
||||
if (inherited != own && isHintedHelperBody(inherited)) {
|
||||
return inherited;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isHintedHelperBody(MethodDeclaration md) {
|
||||
return md != null
|
||||
&& md.getBody() != null
|
||||
&& IdentityStringTransformSupport.returnExpressionsHaveExpandHint(md);
|
||||
}
|
||||
|
||||
private static String findPathMethodByName(List<String> path, String methodName) {
|
||||
if (path == null || methodName == null) {
|
||||
return null;
|
||||
}
|
||||
for (int i = path.size() - 1; i >= 0; i--) {
|
||||
String methodFqn = path.get(i);
|
||||
if (methodFqn != null && methodFqn.endsWith("." + methodName)) {
|
||||
return methodFqn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String findPrecedingConcretePathClass(List<String> path, String callerClass) {
|
||||
if (path == null || callerClass == null) {
|
||||
return null;
|
||||
}
|
||||
for (int i = path.size() - 1; i >= 0; i--) {
|
||||
String methodFqn = path.get(i);
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
continue;
|
||||
}
|
||||
String classFqn = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
if (!callerClass.equals(classFqn)) {
|
||||
return classFqn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String enclosingClassFqn(MethodDeclaration enclosing) {
|
||||
if (enclosing == null) {
|
||||
return null;
|
||||
}
|
||||
ASTNode parent = enclosing.getParent();
|
||||
while (parent != null && !(parent instanceof TypeDeclaration)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
if (parent instanceof TypeDeclaration td) {
|
||||
return context.getFqn(td);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String inferEnumTypeFromMethod(MethodDeclaration methodDeclaration) {
|
||||
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
final String[] enumType = {null};
|
||||
methodDeclaration.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if (!"valueOf".equals(node.getName().getIdentifier()) || node.getExpression() == null) {
|
||||
return super.visit(node);
|
||||
}
|
||||
String receiver = typeNameOf(node.getExpression());
|
||||
if (receiver != null && !receiver.isBlank() && Character.isUpperCase(receiver.charAt(0))) {
|
||||
enumType[0] = receiver;
|
||||
return false;
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return enumType[0];
|
||||
}
|
||||
|
||||
private List<String> qualifyMembers(List<String> members, String enumType) {
|
||||
if (members == null || members.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
String simpleType = simpleEnumTypeName(enumType);
|
||||
if (simpleType == null) {
|
||||
return List.copyOf(members);
|
||||
}
|
||||
List<String> qualified = new ArrayList<>();
|
||||
for (String member : members) {
|
||||
if (member == null || member.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String normalized = qualifyMember(member, simpleType);
|
||||
if (normalized != null && !qualified.contains(normalized)) {
|
||||
qualified.add(normalized);
|
||||
}
|
||||
}
|
||||
return qualified;
|
||||
}
|
||||
|
||||
private static String qualifyMember(String member, String simpleType) {
|
||||
if (member == null || member.isBlank() || simpleType == null) {
|
||||
return member;
|
||||
}
|
||||
if (member.startsWith("<SYMBOLIC:") || member.startsWith("ENUM_SET:")) {
|
||||
return member;
|
||||
}
|
||||
String parsed = member.contains(".")
|
||||
? member.substring(member.lastIndexOf('.', member.lastIndexOf('.') - 1) + 1)
|
||||
: member;
|
||||
if (parsed.contains(".")) {
|
||||
return parsed;
|
||||
}
|
||||
if (parsed.matches("[A-Z_][A-Z0-9_]*")) {
|
||||
return simpleType + "." + parsed;
|
||||
}
|
||||
return member;
|
||||
}
|
||||
|
||||
private static String simpleEnumTypeName(String enumType) {
|
||||
if (enumType == null || enumType.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return enumType.contains(".")
|
||||
? enumType.substring(enumType.lastIndexOf('.') + 1)
|
||||
: enumType;
|
||||
}
|
||||
|
||||
private List<String> expandDeclaredEnumMembers(String declaredType) {
|
||||
if (declaredType == null || declaredType.isBlank() || context == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> enumValues = context.getEnumValues(declaredType);
|
||||
if ((enumValues == null || enumValues.isEmpty()) && declaredType.contains(".")) {
|
||||
enumValues = context.getEnumValues(declaredType.substring(declaredType.lastIndexOf('.') + 1));
|
||||
}
|
||||
if (enumValues == null || enumValues.isEmpty()) {
|
||||
TypeDeclaration td = context.getTypeDeclaration(declaredType);
|
||||
if (td == null && declaredType.contains(".")) {
|
||||
td = context.getTypeDeclaration(declaredType.substring(declaredType.lastIndexOf('.') + 1));
|
||||
}
|
||||
if (td != null) {
|
||||
String fqn = context.getFqn(td);
|
||||
if (fqn != null) {
|
||||
enumValues = context.getEnumValues(fqn);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enumValues == null || enumValues.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
String simpleType = simpleEnumTypeName(declaredType);
|
||||
List<String> expanded = new ArrayList<>();
|
||||
for (String enumValue : enumValues) {
|
||||
if (enumValue == null || enumValue.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String parsed = enumValue.contains(".")
|
||||
? enumValue.substring(enumValue.lastIndexOf('.', enumValue.lastIndexOf('.') - 1) + 1)
|
||||
: enumValue;
|
||||
String qualified = qualifyMember(parsed, simpleType);
|
||||
if (qualified != null && !expanded.contains(qualified)) {
|
||||
expanded.add(qualified);
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
public static boolean isSendLikeName(String name) {
|
||||
return name != null && SEND_LIKE_NAMES.contains(name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.flow.eventid;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Shared result for unique-or-hint-gated enum expand.
|
||||
* Members are concrete only (never {@code ENUM_SET:}/{@code SYMBOLIC} tokens).
|
||||
* <p>
|
||||
* <b>Publish contract:</b> do <em>not</em> publish {@link Source#UNIQUE} into polymorphic
|
||||
* events from this consult — use {@code recoverFromPath} / {@code resolveUnique} instead.
|
||||
* Poly widen only when {@link #adoptableAsPolyExpand()} is true (hint-licensed expand).
|
||||
* Do not treat non-empty {@link #members()} as expand without that check.
|
||||
*/
|
||||
public final class ExpandConsult {
|
||||
|
||||
public enum Source {
|
||||
UNIQUE,
|
||||
ENUM_EXPAND,
|
||||
EMPTY
|
||||
}
|
||||
|
||||
private final List<String> members;
|
||||
private final Source source;
|
||||
private final boolean licensedByHint;
|
||||
private final String enumType;
|
||||
|
||||
private ExpandConsult(List<String> members, Source source, boolean licensedByHint, String enumType) {
|
||||
this.members = members == null ? List.of() : List.copyOf(members);
|
||||
this.source = source == null ? Source.EMPTY : source;
|
||||
this.licensedByHint = licensedByHint;
|
||||
this.enumType = enumType;
|
||||
}
|
||||
|
||||
public static ExpandConsult empty() {
|
||||
return new ExpandConsult(List.of(), Source.EMPTY, false, null);
|
||||
}
|
||||
|
||||
public static ExpandConsult unique(List<String> members, String enumType) {
|
||||
if (members == null || members.size() != 1) {
|
||||
return empty();
|
||||
}
|
||||
return new ExpandConsult(members, Source.UNIQUE, false, enumType);
|
||||
}
|
||||
|
||||
public static ExpandConsult enumExpand(List<String> members, String enumType) {
|
||||
if (members == null || members.isEmpty()) {
|
||||
return empty();
|
||||
}
|
||||
return new ExpandConsult(members, Source.ENUM_EXPAND, true, enumType);
|
||||
}
|
||||
|
||||
public List<String> members() {
|
||||
return members;
|
||||
}
|
||||
|
||||
public Source source() {
|
||||
return source;
|
||||
}
|
||||
|
||||
public boolean licensedByHint() {
|
||||
return licensedByHint;
|
||||
}
|
||||
|
||||
public String enumType() {
|
||||
return enumType;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return source == Source.EMPTY || members.isEmpty();
|
||||
}
|
||||
|
||||
/** Hint-licensed multi-member widen suitable for polymorphicEvents. */
|
||||
public boolean adoptableAsPolyExpand() {
|
||||
return source == Source.ENUM_EXPAND && !members.isEmpty();
|
||||
}
|
||||
|
||||
/** Single proven constant — publish via unique/recover, not poly expand. */
|
||||
public boolean isUniqueProof() {
|
||||
return source == Source.UNIQUE && members.size() == 1;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
import org.eclipse.jdt.core.dom.ITypeBinding;
|
||||
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||
import org.eclipse.jdt.core.dom.SimpleName;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Chooses which argument of a send-like call is the event expression.
|
||||
* Fail-closed when multi-arg shapes are ambiguous.
|
||||
*/
|
||||
public final class EventArgumentSelector {
|
||||
|
||||
private EventArgumentSelector() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the event argument expression, or empty when none / ambiguous
|
||||
*/
|
||||
public static Optional<Expression> selectEventArgument(MethodInvocation call) {
|
||||
if (call == null || call.arguments().isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Expression> args = new ArrayList<>();
|
||||
for (Object argObj : call.arguments()) {
|
||||
if (argObj instanceof Expression expr) {
|
||||
args.add(expr);
|
||||
}
|
||||
}
|
||||
if (args.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (args.size() == 1) {
|
||||
return Optional.of(args.get(0));
|
||||
}
|
||||
|
||||
Expression arg0 = args.get(0);
|
||||
Expression arg1 = args.get(1);
|
||||
|
||||
if (looksLikeHeaders(arg0)) {
|
||||
for (int i = 1; i < args.size(); i++) {
|
||||
if (looksLikeEvent(args.get(i))) {
|
||||
return Optional.of(args.get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (looksLikeEvent(arg0) && looksLikeState(arg1)) {
|
||||
return Optional.of(arg0);
|
||||
}
|
||||
|
||||
Expression soleEnumOrMessage = null;
|
||||
int hits = 0;
|
||||
for (Expression arg : args) {
|
||||
if (looksLikeEnumOrMessageType(arg)) {
|
||||
hits++;
|
||||
soleEnumOrMessage = arg;
|
||||
}
|
||||
}
|
||||
if (hits == 1) {
|
||||
return Optional.of(soleEnumOrMessage);
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** True when {@code selected} is the same AST node as {@code call}'s first argument. */
|
||||
public static boolean selectedIsFirstArgument(MethodInvocation call, Expression selected) {
|
||||
if (call == null || selected == null || call.arguments().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return call.arguments().get(0) == selected;
|
||||
}
|
||||
|
||||
static boolean looksLikeHeaders(Expression expr) {
|
||||
if (expr == null) {
|
||||
return false;
|
||||
}
|
||||
String name = simpleNameOf(expr);
|
||||
if (name != null) {
|
||||
String lower = name.toLowerCase(Locale.ROOT);
|
||||
if (lower.contains("header") || "headers".equals(lower) || "hdrs".equals(lower)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String type = typeSimpleName(expr);
|
||||
if (type != null) {
|
||||
String lower = type.toLowerCase(Locale.ROOT);
|
||||
if (lower.contains("header") || "map".equals(lower) || lower.endsWith("map")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean looksLikeEvent(Expression expr) {
|
||||
if (expr == null) {
|
||||
return false;
|
||||
}
|
||||
if (looksLikeEnumOrMessageType(expr)) {
|
||||
return true;
|
||||
}
|
||||
String name = simpleNameOf(expr);
|
||||
if (name != null) {
|
||||
String lower = name.toLowerCase(Locale.ROOT);
|
||||
if ("event".equals(lower)
|
||||
|| "payload".equals(lower)
|
||||
|| "e".equals(lower)
|
||||
|| "ev".equals(lower)
|
||||
|| lower.endsWith("event")
|
||||
|| lower.endsWith("payload")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String text = expr.toString();
|
||||
return text != null && (text.contains("valueOf") || text.contains("Event."));
|
||||
}
|
||||
|
||||
static boolean looksLikeState(Expression expr) {
|
||||
if (expr == null) {
|
||||
return false;
|
||||
}
|
||||
String name = simpleNameOf(expr);
|
||||
if (name != null) {
|
||||
String lower = name.toLowerCase(Locale.ROOT);
|
||||
if ("state".equals(lower)
|
||||
|| "source".equals(lower)
|
||||
|| "sourcestate".equals(lower)
|
||||
|| "current".equals(lower)
|
||||
|| lower.endsWith("state")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String type = typeSimpleName(expr);
|
||||
if (type != null) {
|
||||
String lower = type.toLowerCase(Locale.ROOT);
|
||||
if (lower.contains("state") && !lower.contains("statemachine")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String text = expr.toString();
|
||||
return text != null && text.contains("State.");
|
||||
}
|
||||
|
||||
static boolean looksLikeEnumOrMessageType(Expression expr) {
|
||||
if (expr == null) {
|
||||
return false;
|
||||
}
|
||||
ITypeBinding binding = expr.resolveTypeBinding();
|
||||
if (binding != null) {
|
||||
if (binding.isEnum()) {
|
||||
return true;
|
||||
}
|
||||
String qn = binding.getQualifiedName();
|
||||
if (qn != null && (qn.equals("org.springframework.messaging.Message")
|
||||
|| qn.startsWith("org.springframework.messaging.Message<"))) {
|
||||
return true;
|
||||
}
|
||||
String simple = binding.getName();
|
||||
if (simple != null && "Message".equals(simple)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String type = typeSimpleName(expr);
|
||||
if (type != null && ("Message".equals(type) || type.endsWith("Message"))) {
|
||||
return true;
|
||||
}
|
||||
return textSuggestsEnumConstant(expr);
|
||||
}
|
||||
|
||||
private static boolean textSuggestsEnumConstant(Expression expr) {
|
||||
String text = expr.toString();
|
||||
return text != null && (text.contains(".valueOf")
|
||||
|| text.matches(".*\\b[A-Z][A-Za-z0-9_]*\\.[A-Z][A-Z0-9_]+\\b.*"));
|
||||
}
|
||||
|
||||
private static String simpleNameOf(Expression expr) {
|
||||
if (expr instanceof SimpleName sn) {
|
||||
return sn.getIdentifier();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String typeSimpleName(Expression expr) {
|
||||
ITypeBinding binding = expr.resolveTypeBinding();
|
||||
if (binding != null) {
|
||||
String name = binding.getName();
|
||||
if (name != null && !name.isBlank()) {
|
||||
int generic = name.indexOf('<');
|
||||
return generic >= 0 ? name.substring(0, generic) : name;
|
||||
}
|
||||
String qn = binding.getErasure().getQualifiedName();
|
||||
if (qn != null && qn.contains(".")) {
|
||||
return qn.substring(qn.lastIndexOf('.') + 1);
|
||||
}
|
||||
return qn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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.ExpressionMethodReference;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
|
||||
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||
import org.eclipse.jdt.core.dom.SimpleName;
|
||||
import org.eclipse.jdt.core.dom.TypeMethodReference;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Shared string-transform policy for EventIdentity peels and ACE enum-expansion hints.
|
||||
* <p>
|
||||
* Two roles (Locale arity intentionally split):
|
||||
* <ul>
|
||||
* <li><b>Peel</b> — identity unwrap for unique proofs: 0-arg {@code trim}/{@code strip}/
|
||||
* {@code toUpperCase}/{@code toLowerCase}. Locale arity stays unpeeled (fail-closed).</li>
|
||||
* <li><b>Expand hint</b> — license to widen unbound {@code <SYMBOLIC: Enum.*>}: 0-arg
|
||||
* {@code name()}, plus {@code toUpperCase}/{@code toLowerCase} with any arity,
|
||||
* including method references ({@code String::toUpperCase}, {@code Enum::name}).
|
||||
* {@code trim}/{@code strip} alone do not expand.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class IdentityStringTransformSupport {
|
||||
|
||||
private static final Set<String> PEEL_NAMES = Set.of(
|
||||
"trim", "strip", "toUpperCase", "toLowerCase");
|
||||
|
||||
private static final Set<String> EXPAND_HINT_NAMES = Set.of(
|
||||
"name", "toUpperCase", "toLowerCase");
|
||||
|
||||
private IdentityStringTransformSupport() {
|
||||
}
|
||||
|
||||
/** Identity-preserving String instance method with 0 args. */
|
||||
public static boolean isPeelInvocation(MethodInvocation invocation) {
|
||||
if (invocation == null || invocation.getName() == null) {
|
||||
return false;
|
||||
}
|
||||
if (!PEEL_NAMES.contains(invocation.getName().getIdentifier())) {
|
||||
return false;
|
||||
}
|
||||
return invocation.arguments().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* License to widen SYMBOLIC poly: 0-arg {@code name()}, or case transforms any arity.
|
||||
* Does not match {@code getName()} or {@code trim}/{@code strip} alone.
|
||||
*/
|
||||
public static boolean isExpandHintInvocation(MethodInvocation invocation) {
|
||||
if (invocation == null || invocation.getName() == null) {
|
||||
return false;
|
||||
}
|
||||
String name = invocation.getName().getIdentifier();
|
||||
if ("name".equals(name)) {
|
||||
return invocation.arguments().isEmpty();
|
||||
}
|
||||
return "toUpperCase".equals(name) || "toLowerCase".equals(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method references that carry the same expand license as invocations
|
||||
* ({@code String::toUpperCase}, {@code OrderEvent::name}). Not peels.
|
||||
*/
|
||||
public static boolean isExpandHintMethodReference(Expression expression) {
|
||||
String name = methodReferenceName(expression);
|
||||
if (name == null) {
|
||||
return false;
|
||||
}
|
||||
return EXPAND_HINT_NAMES.contains(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeatedly unwraps 0-arg peel invocations (and parentheses). Does not peel casts,
|
||||
* Optional/container wrappers, or method references — callers own those.
|
||||
*/
|
||||
public static Expression peelIdentityStringOps(Expression expression) {
|
||||
Expression current = expression;
|
||||
boolean progressed;
|
||||
do {
|
||||
progressed = false;
|
||||
while (current instanceof ParenthesizedExpression pe) {
|
||||
current = pe.getExpression();
|
||||
progressed = true;
|
||||
}
|
||||
if (current instanceof MethodInvocation mi
|
||||
&& mi.getExpression() != null
|
||||
&& isPeelInvocation(mi)) {
|
||||
current = mi.getExpression();
|
||||
progressed = true;
|
||||
}
|
||||
} while (progressed);
|
||||
return current;
|
||||
}
|
||||
|
||||
public static boolean expressionHasExpandHint(Expression expr) {
|
||||
return nodeHasExpandHint(expr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-body scan (includes logging / dead code). Prefer
|
||||
* {@link #returnExpressionsHaveExpandHint(MethodDeclaration)} for ACE helper follow-up.
|
||||
*/
|
||||
public static boolean methodBodyHasExpandHint(MethodDeclaration methodDeclaration) {
|
||||
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
|
||||
return false;
|
||||
}
|
||||
return nodeHasExpandHint(methodDeclaration.getBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* True when any {@code return} expression carries an expand hint.
|
||||
* Side-effect invocations elsewhere in the body do not count.
|
||||
*/
|
||||
public static boolean returnExpressionsHaveExpandHint(MethodDeclaration methodDeclaration) {
|
||||
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
|
||||
return false;
|
||||
}
|
||||
final boolean[] hint = {false};
|
||||
methodDeclaration.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(ReturnStatement node) {
|
||||
if (expressionHasExpandHint(node.getExpression())) {
|
||||
hint[0] = true;
|
||||
return false;
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return hint[0];
|
||||
}
|
||||
|
||||
private static boolean nodeHasExpandHint(ASTNode node) {
|
||||
if (node == null) {
|
||||
return false;
|
||||
}
|
||||
final boolean[] hint = {false};
|
||||
node.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation invocation) {
|
||||
if (isExpandHintInvocation(invocation)) {
|
||||
hint[0] = true;
|
||||
return false;
|
||||
}
|
||||
return super.visit(invocation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean visit(ExpressionMethodReference node) {
|
||||
if (isExpandHintMethodReference(node)) {
|
||||
hint[0] = true;
|
||||
return false;
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean visit(TypeMethodReference node) {
|
||||
if (isExpandHintMethodReference(node)) {
|
||||
hint[0] = true;
|
||||
return false;
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return hint[0];
|
||||
}
|
||||
|
||||
private static String methodReferenceName(Expression expression) {
|
||||
if (expression instanceof ExpressionMethodReference emr && emr.getName() != null) {
|
||||
return emr.getName().getIdentifier();
|
||||
}
|
||||
if (expression instanceof TypeMethodReference tmr && tmr.getName() != null) {
|
||||
return tmr.getName().getIdentifier();
|
||||
}
|
||||
if (expression instanceof SimpleName) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.flow.alloc.AllocationFactPropagator;
|
||||
import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.flow.eventid.EventIdentityResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.flow.eventid.EventIdentityNormalize;
|
||||
import click.kamil.springstatemachineexporter.analysis.flow.eventid.EventIdentityPolyPublish;
|
||||
import click.kamil.springstatemachineexporter.analysis.flow.eventid.ExpandConsult;
|
||||
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.EnumExpansionHintSupport;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.EventArgumentSelector;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
|
||||
@@ -1293,7 +1296,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
// sendEvent arg may already be <SYMBOLIC:Enum.*> / full enum widen while the dispatcher
|
||||
// frame still has valueOf(wrapper.getEvent().getType()) with a proven CIC — narrow here.
|
||||
if (polymorphicEvents.size() != 1
|
||||
// Skip only when poly already holds a unique concrete (SYMBOLIC size==1 must still recover).
|
||||
if (!hasUniqueConcretePolymorphic(polymorphicEvents)
|
||||
&& tryRecoverAllocationSensitiveInto(path, callGraph, polymorphicEvents)) {
|
||||
isAmbiguous = false;
|
||||
}
|
||||
@@ -3623,27 +3627,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return constants;
|
||||
}
|
||||
|
||||
private List<String> expandDeclaredEnumValues(String declaredType) {
|
||||
if (declaredType == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> enumValues = context.getEnumValues(declaredType);
|
||||
if ((enumValues == null || enumValues.isEmpty()) && declaredType.contains(".")) {
|
||||
enumValues = context.getEnumValues(declaredType.substring(declaredType.lastIndexOf('.') + 1));
|
||||
}
|
||||
if (enumValues == null || enumValues.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> expanded = new ArrayList<>();
|
||||
for (String enumValue : enumValues) {
|
||||
String parsed = constantExtractor.parseEnumSetElement(enumValue);
|
||||
if (parsed != null && !expanded.contains(parsed)) {
|
||||
expanded.add(parsed);
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
private boolean isRuntimeEnumParameter(Expression exprNode) {
|
||||
if (!(exprNode instanceof MethodInvocation mi)) {
|
||||
return false;
|
||||
@@ -3781,34 +3764,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (polymorphicEvents == null) {
|
||||
return polymorphicEvents;
|
||||
}
|
||||
// Empty poly: expand only via name()/toUpperCase caller hints (PolymorphicDispatch).
|
||||
// Shared EI expand consult — ACE only publishes ENUM_EXPAND via shared policy.
|
||||
// UNIQUE without hint stays on tryRecoverAllocationSensitiveInto (fail-closed disagree).
|
||||
ExpandConsult consult = tryExpandViaEventIdentity(path, callGraph);
|
||||
List<String> fromEi = EventIdentityPolyPublish.adoptExpandOrEmpty(consult);
|
||||
boolean eiExpand = !fromEi.isEmpty();
|
||||
if (polymorphicEvents.isEmpty()) {
|
||||
if (callerArgumentHasEnumExpansionHint(path)) {
|
||||
List<String> fromCaller = resolvePolymorphicEventsFromCallerArgument(path);
|
||||
if (fromCaller.stream().anyMatch(this::looksLikeEnumConstant)) {
|
||||
return fromCaller;
|
||||
}
|
||||
if (eiExpand) {
|
||||
return fromEi;
|
||||
}
|
||||
return polymorphicEvents;
|
||||
}
|
||||
Map<String, String> bindings = resolvePathBindings(path, callGraph, Map.of());
|
||||
if (bindings.isEmpty()) {
|
||||
boolean symbolicOnly = polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant);
|
||||
if (symbolicOnly) {
|
||||
List<String> hinted = resolvePolymorphicEventsFromCallerArgument(path);
|
||||
// Only adopt when caller-arg path set an expansion hint (toUpperCase/name),
|
||||
// detected as concrete enum members without relying on multi-class widen alone.
|
||||
if (hinted.stream().anyMatch(this::looksLikeEnumConstant)
|
||||
&& callerArgumentHasEnumExpansionHint(path)) {
|
||||
return hinted;
|
||||
}
|
||||
}
|
||||
if (hasMultiClassPolymorphicPath(path)
|
||||
&& polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) {
|
||||
List<String> callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path);
|
||||
if (!callerArgumentResolved.isEmpty()) {
|
||||
return callerArgumentResolved;
|
||||
}
|
||||
if (eiExpand && symbolicOnly) {
|
||||
return fromEi;
|
||||
}
|
||||
return polymorphicEvents;
|
||||
}
|
||||
@@ -3817,22 +3788,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
List<String> bindingResolved = resolvePolymorphicEventsFromBindings(bindings, path);
|
||||
boolean bindingHasConcrete = bindingResolved.stream().anyMatch(this::looksLikeEnumConstant);
|
||||
if (bindingHasConcrete) {
|
||||
narrowed = new ArrayList<>(bindingResolved);
|
||||
narrowed = new ArrayList<>();
|
||||
for (String candidate : bindingResolved) {
|
||||
addConstantPreferQualified(narrowed, candidate);
|
||||
}
|
||||
} else {
|
||||
for (String candidate : bindingResolved) {
|
||||
if (candidate != null && !narrowed.contains(candidate)) {
|
||||
narrowed.add(candidate);
|
||||
}
|
||||
addConstantPreferQualified(narrowed, candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean originallySymbolicOnly = polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant);
|
||||
if (originallySymbolicOnly && narrowed.stream().filter(this::looksLikeEnumConstant).count() <= 1) {
|
||||
List<String> callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path);
|
||||
for (String candidate : callerArgumentResolved) {
|
||||
if (candidate != null && !narrowed.contains(candidate)) {
|
||||
narrowed.add(candidate);
|
||||
}
|
||||
if (originallySymbolicOnly
|
||||
&& narrowed.stream().filter(this::looksLikeEnumConstant).count() <= 1
|
||||
&& eiExpand) {
|
||||
for (String candidate : fromEi) {
|
||||
addConstantPreferQualified(narrowed, candidate);
|
||||
}
|
||||
}
|
||||
boolean hasConcrete = narrowed.stream().anyMatch(this::looksLikeEnumConstant);
|
||||
@@ -3846,6 +3817,67 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return narrowed;
|
||||
}
|
||||
|
||||
/** Merge bare/qualified enum constants without double-counting. */
|
||||
private void addConstantPreferQualified(List<String> target, String candidate) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < target.size(); i++) {
|
||||
if (EventIdentityNormalize.sameConstant(target.get(i), candidate)) {
|
||||
target.set(i, EventIdentityNormalize.preferQualified(target.get(i), candidate, null));
|
||||
return;
|
||||
}
|
||||
}
|
||||
target.add(candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared EI expand for hint-gated unbound valueOf / string transforms / helper returns.
|
||||
*/
|
||||
private ExpandConsult tryExpandViaEventIdentity(
|
||||
List<String> path,
|
||||
Map<String, List<CallEdge>> callGraph) {
|
||||
if (eventIdentityResolver == null || path == null || path.size() < 2) {
|
||||
return ExpandConsult.empty();
|
||||
}
|
||||
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 ExpandConsult.empty();
|
||||
}
|
||||
MethodDeclaration callerDeclaration = context.findMethodDeclaration(callerType, callerMethodName, true);
|
||||
if (callerDeclaration == null || callerDeclaration.getBody() == null) {
|
||||
return ExpandConsult.empty();
|
||||
}
|
||||
Expression[] eventArg = {null};
|
||||
callerDeclaration.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if (!targetMethodName.equals(node.getName().getIdentifier()) || node.arguments().isEmpty()) {
|
||||
return super.visit(node);
|
||||
}
|
||||
eventArg[0] = EventArgumentSelector.selectEventArgument(node).orElse(null);
|
||||
return eventArg[0] == null;
|
||||
}
|
||||
});
|
||||
if (eventArg[0] == null) {
|
||||
return ExpandConsult.empty();
|
||||
}
|
||||
Map<String, List<CallEdge>> graphForFacts = callGraph != null ? callGraph : this.graph;
|
||||
return eventIdentityResolver.resolveExpandConsult(eventArg[0], path, graphForFacts);
|
||||
}
|
||||
|
||||
private boolean hasUniqueConcretePolymorphic(List<String> polymorphicEvents) {
|
||||
if (polymorphicEvents == null || polymorphicEvents.size() != 1) {
|
||||
return false;
|
||||
}
|
||||
return looksLikeEnumConstant(polymorphicEvents.get(0));
|
||||
}
|
||||
|
||||
private List<String> resolvePolymorphicEventsFromBindings(
|
||||
Map<String, String> bindings,
|
||||
List<String> path) {
|
||||
@@ -3895,344 +3927,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return resolved.stream().distinct().collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
private List<String> resolvePolymorphicEventsFromCallerArgument(List<String> path) {
|
||||
if (path == null || path.size() < 2) {
|
||||
return List.of();
|
||||
}
|
||||
String callerMethod = path.get(path.size() - 2);
|
||||
String targetMethod = path.get(path.size() - 1);
|
||||
String callerClass = callerMethod.substring(0, callerMethod.lastIndexOf('.'));
|
||||
String callerMethodName = callerMethod.substring(callerMethod.lastIndexOf('.') + 1);
|
||||
String targetMethodName = targetMethod.substring(targetMethod.lastIndexOf('.') + 1);
|
||||
TypeDeclaration callerType = context.getTypeDeclaration(callerClass);
|
||||
if (callerType == null) {
|
||||
return List.of();
|
||||
}
|
||||
MethodDeclaration callerDeclaration = context.findMethodDeclaration(callerType, callerMethodName, true);
|
||||
if (callerDeclaration == null || callerDeclaration.getBody() == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> resolved = new ArrayList<>();
|
||||
final boolean[] expansionHint = {false};
|
||||
callerDeclaration.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if (!targetMethodName.equals(node.getName().getIdentifier()) || node.arguments().isEmpty()) {
|
||||
return super.visit(node);
|
||||
}
|
||||
Expression argument = (Expression) node.arguments().get(0);
|
||||
List<Expression> candidateExpressions = new ArrayList<>();
|
||||
if (argument instanceof SimpleName && variableTracer != null) {
|
||||
candidateExpressions.addAll(variableTracer.traceVariableAll(argument));
|
||||
}
|
||||
if (candidateExpressions.isEmpty()) {
|
||||
candidateExpressions.add(argument);
|
||||
}
|
||||
for (Expression candidateExpr : candidateExpressions) {
|
||||
if (candidateExpr == null) {
|
||||
continue;
|
||||
}
|
||||
if (EnumExpansionHintSupport.expressionHasHint(candidateExpr)) {
|
||||
expansionHint[0] = true;
|
||||
}
|
||||
if (candidateExpr instanceof MethodInvocation localMethod
|
||||
&& (localMethod.getExpression() == null
|
||||
|| localMethod.getExpression() instanceof ThisExpression)) {
|
||||
String resolvedPathMethod = findPathMethodByName(path, localMethod.getName().getIdentifier());
|
||||
if (resolvedPathMethod == null) {
|
||||
String fallbackOwner = findPrecedingConcretePathClass(path, callerClass);
|
||||
if (fallbackOwner != null) {
|
||||
TypeDeclaration fallbackType = context.getTypeDeclaration(fallbackOwner);
|
||||
MethodDeclaration fallbackMethod = fallbackType != null
|
||||
? context.findMethodDeclaration(
|
||||
fallbackType, localMethod.getName().getIdentifier(), true)
|
||||
: null;
|
||||
if (fallbackMethod != null) {
|
||||
resolvedPathMethod = fallbackOwner + "." + localMethod.getName().getIdentifier();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resolvedPathMethod == null
|
||||
&& context.findMethodDeclaration(
|
||||
callerType, localMethod.getName().getIdentifier(), true) != null) {
|
||||
resolvedPathMethod = callerClass + "." + localMethod.getName().getIdentifier();
|
||||
}
|
||||
if (resolvedPathMethod != null && resolvedPathMethod.contains(".")) {
|
||||
String ownerFqn = resolvedPathMethod.substring(0, resolvedPathMethod.lastIndexOf('.'));
|
||||
String methodName = resolvedPathMethod.substring(resolvedPathMethod.lastIndexOf('.') + 1);
|
||||
TypeDeclaration pathOwnerType = context.getTypeDeclaration(ownerFqn);
|
||||
MethodDeclaration pathMethodDeclaration = pathOwnerType != null
|
||||
? context.findMethodDeclaration(pathOwnerType, methodName, true)
|
||||
: null;
|
||||
if (EnumExpansionHintSupport.methodBodyHasHint(pathMethodDeclaration)) {
|
||||
expansionHint[0] = true;
|
||||
String hintedEnumType = inferEnumTypeFromMethod(pathMethodDeclaration);
|
||||
if (hintedEnumType != null) {
|
||||
for (String expanded : expandDeclaredEnumValues(hintedEnumType)) {
|
||||
addResolvedConstantToList(expanded, resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String candidate : constantExtractor.resolveMethodReturnConstant(
|
||||
ownerFqn, methodName, 0, new HashSet<>(), null)) {
|
||||
addResolvedConstantToList(candidate, resolved);
|
||||
}
|
||||
}
|
||||
for (String candidate : resolveLocalSwitchMethodConstants(callerType, localMethod)) {
|
||||
addResolvedConstantToList(candidate, resolved);
|
||||
}
|
||||
}
|
||||
String directResolved = constantResolver.resolve(candidateExpr, context);
|
||||
if (directResolved != null) {
|
||||
addResolvedConstantToList(directResolved, resolved);
|
||||
}
|
||||
List<String> constants = new ArrayList<>();
|
||||
constantExtractor.extractConstantsFromExpression(candidateExpr, constants);
|
||||
for (String candidate : constants) {
|
||||
addResolvedConstantToList(candidate, resolved);
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
List<String> distinct = resolved.stream().distinct().collect(java.util.stream.Collectors.toList());
|
||||
if (distinct.stream().noneMatch(AbstractCallGraphEngine.this::looksLikeEnumConstant)) {
|
||||
for (int i = Math.max(0, path.size() - 2); i >= 0; i--) {
|
||||
String pathMethod = path.get(i);
|
||||
if (pathMethod == null || !pathMethod.contains(".")) {
|
||||
continue;
|
||||
}
|
||||
String ownerFqn = pathMethod.substring(0, pathMethod.lastIndexOf('.'));
|
||||
String methodName = pathMethod.substring(pathMethod.lastIndexOf('.') + 1);
|
||||
for (String candidate : constantExtractor.resolveMethodReturnConstant(
|
||||
ownerFqn, methodName, 0, new HashSet<>(), null)) {
|
||||
addResolvedConstantToList(candidate, distinct);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((expansionHint[0] || hasMultiClassPolymorphicPath(path))
|
||||
&& distinct.stream().noneMatch(AbstractCallGraphEngine.this::looksLikeEnumConstant)) {
|
||||
List<String> expanded = expandSingleSymbolicType(distinct);
|
||||
if (expanded.isEmpty() && (expansionHint[0] || hasMultiClassPolymorphicPath(path))) {
|
||||
expanded = inferEnumValuesFromPath(path);
|
||||
}
|
||||
if (expanded.isEmpty() && expansionHint[0]) {
|
||||
String enumType = inferEnumTypeFromMethod(callerDeclaration);
|
||||
if (enumType != null) {
|
||||
expanded = expandDeclaredEnumValues(enumType);
|
||||
}
|
||||
}
|
||||
if (!expanded.isEmpty()) {
|
||||
return expanded;
|
||||
}
|
||||
}
|
||||
return distinct;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the sendEvent (or trigger) argument at the caller shows a dynamic string
|
||||
* transform ({@code toUpperCase}/{@code name}) that should widen SYMBOLIC to enum members.
|
||||
*/
|
||||
private boolean callerArgumentHasEnumExpansionHint(List<String> path) {
|
||||
if (path == null || path.size() < 2) {
|
||||
return false;
|
||||
}
|
||||
String callerMethod = path.get(path.size() - 2);
|
||||
String targetMethod = path.get(path.size() - 1);
|
||||
String callerClass = callerMethod.substring(0, callerMethod.lastIndexOf('.'));
|
||||
String callerMethodName = callerMethod.substring(callerMethod.lastIndexOf('.') + 1);
|
||||
String targetMethodName = targetMethod.substring(targetMethod.lastIndexOf('.') + 1);
|
||||
TypeDeclaration callerType = context.getTypeDeclaration(callerClass);
|
||||
if (callerType == null) {
|
||||
return false;
|
||||
}
|
||||
MethodDeclaration callerDeclaration = context.findMethodDeclaration(callerType, callerMethodName, true);
|
||||
if (callerDeclaration == null || callerDeclaration.getBody() == null) {
|
||||
return false;
|
||||
}
|
||||
final boolean[] hint = {false};
|
||||
callerDeclaration.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if (!targetMethodName.equals(node.getName().getIdentifier()) || node.arguments().isEmpty()) {
|
||||
return super.visit(node);
|
||||
}
|
||||
Expression argument = (Expression) node.arguments().get(0);
|
||||
List<Expression> toCheck = new ArrayList<>();
|
||||
if (argument instanceof SimpleName && variableTracer != null) {
|
||||
toCheck.addAll(variableTracer.traceVariableAll(argument));
|
||||
}
|
||||
if (toCheck.isEmpty() && argument != null) {
|
||||
toCheck.add(argument);
|
||||
}
|
||||
for (Expression argumentExpr : toCheck) {
|
||||
if (argumentExpr != null && EnumExpansionHintSupport.expressionHasHint(argumentExpr)) {
|
||||
hint[0] = true;
|
||||
return false;
|
||||
}
|
||||
if (argumentExpr instanceof MethodInvocation localMethod
|
||||
&& (localMethod.getExpression() == null
|
||||
|| localMethod.getExpression() instanceof ThisExpression)) {
|
||||
String owner = findPathMethodByName(path, localMethod.getName().getIdentifier());
|
||||
if (owner == null) {
|
||||
String fallbackOwner = findPrecedingConcretePathClass(path, callerClass);
|
||||
if (fallbackOwner != null) {
|
||||
owner = fallbackOwner + "." + localMethod.getName().getIdentifier();
|
||||
}
|
||||
}
|
||||
if (owner == null) {
|
||||
// Same-class private helper not always on the call path FQN list
|
||||
MethodDeclaration localMd = context.findMethodDeclaration(
|
||||
callerType, localMethod.getName().getIdentifier(), true);
|
||||
if (EnumExpansionHintSupport.methodBodyHasHint(localMd)) {
|
||||
hint[0] = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (owner != null && owner.contains(".")) {
|
||||
String ownerFqn = owner.substring(0, owner.lastIndexOf('.'));
|
||||
String methodName = owner.substring(owner.lastIndexOf('.') + 1);
|
||||
TypeDeclaration td = context.getTypeDeclaration(ownerFqn);
|
||||
MethodDeclaration md = td != null
|
||||
? context.findMethodDeclaration(td, methodName, true) : null;
|
||||
if (EnumExpansionHintSupport.methodBodyHasHint(md)) {
|
||||
hint[0] = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return hint[0];
|
||||
}
|
||||
|
||||
private List<String> inferEnumValuesFromPath(List<String> path) {
|
||||
if (path == null || path.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
for (int i = path.size() - 1; i >= 0; i--) {
|
||||
String methodFqn = path.get(i);
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
continue;
|
||||
}
|
||||
String ownerFqn = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
|
||||
MethodDeclaration methodDeclaration = ownerType != null
|
||||
? context.findMethodDeclaration(ownerType, methodName, true)
|
||||
: null;
|
||||
String enumType = inferEnumTypeFromMethod(methodDeclaration);
|
||||
if (enumType == null && ownerType != null) {
|
||||
for (MethodDeclaration candidateMethod : ownerType.getMethods()) {
|
||||
enumType = inferEnumTypeFromMethod(candidateMethod);
|
||||
if (enumType != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enumType != null) {
|
||||
List<String> expanded = expandDeclaredEnumValues(enumType);
|
||||
if (!expanded.isEmpty()) {
|
||||
return expanded;
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private String inferEnumTypeFromMethod(MethodDeclaration methodDeclaration) {
|
||||
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
final String[] enumType = {null};
|
||||
methodDeclaration.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if (!"valueOf".equals(node.getName().getIdentifier()) || node.getExpression() == null) {
|
||||
return super.visit(node);
|
||||
}
|
||||
String receiver = node.getExpression().toString();
|
||||
if (receiver != null && !receiver.isBlank() && Character.isUpperCase(receiver.charAt(0))) {
|
||||
enumType[0] = receiver;
|
||||
return false;
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return enumType[0];
|
||||
}
|
||||
|
||||
private boolean hasMultiClassPolymorphicPath(List<String> path) {
|
||||
if (path == null || path.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Set<String> classes = new LinkedHashSet<>();
|
||||
for (String methodFqn : path) {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
continue;
|
||||
}
|
||||
classes.add(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
|
||||
}
|
||||
return classes.size() > 2;
|
||||
}
|
||||
|
||||
private String findPrecedingConcretePathClass(List<String> path, String callerClass) {
|
||||
if (path == null || callerClass == null) {
|
||||
return null;
|
||||
}
|
||||
for (int i = path.size() - 1; i >= 0; i--) {
|
||||
String methodFqn = path.get(i);
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
continue;
|
||||
}
|
||||
String classFqn = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
if (!callerClass.equals(classFqn)) {
|
||||
return classFqn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String findPathMethodByName(List<String> path, String methodName) {
|
||||
if (path == null || methodName == null) {
|
||||
return null;
|
||||
}
|
||||
for (int i = path.size() - 1; i >= 0; i--) {
|
||||
String methodFqn = path.get(i);
|
||||
if (methodFqn != null && methodFqn.endsWith("." + methodName)) {
|
||||
return methodFqn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> expandSingleSymbolicType(List<String> values) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<String> normalizedTypes = new LinkedHashSet<>();
|
||||
String preferredType = null;
|
||||
for (String value : values) {
|
||||
if (value == null || !value.startsWith("<SYMBOLIC: ") || !value.endsWith(".*>")) {
|
||||
continue;
|
||||
}
|
||||
String symbolicType = value.substring("<SYMBOLIC: ".length(), value.length() - 3).trim();
|
||||
if (preferredType == null || symbolicType.contains(".")) {
|
||||
preferredType = symbolicType;
|
||||
}
|
||||
String normalized = symbolicType.contains(".")
|
||||
? symbolicType.substring(symbolicType.lastIndexOf('.') + 1)
|
||||
: symbolicType;
|
||||
normalizedTypes.add(normalized);
|
||||
}
|
||||
if (preferredType == null || normalizedTypes.size() != 1) {
|
||||
return List.of();
|
||||
}
|
||||
return expandDeclaredEnumValues(preferredType);
|
||||
}
|
||||
|
||||
private String resolveKeyedValueFromMapExpression(Expression mapExpr, String key) {
|
||||
if (mapExpr == null || key == null) {
|
||||
return null;
|
||||
|
||||
@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.EventArgumentSelector;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.MessageCarrierSupport;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
@@ -281,8 +282,11 @@ public class GenericEventDetector {
|
||||
eventValue = context.resolveString(forcedEvent);
|
||||
} else {
|
||||
if (node.arguments().isEmpty()) return Collections.emptyList();
|
||||
Expression eventExpr = (Expression) node.arguments().get(0);
|
||||
|
||||
Expression eventExpr = EventArgumentSelector.selectEventArgument(node).orElse(null);
|
||||
if (eventExpr == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// Try MessageBuilder extraction first
|
||||
eventValue = extractEventFromMessageBuilder(eventExpr);
|
||||
|
||||
@@ -305,16 +309,21 @@ public class GenericEventDetector {
|
||||
|
||||
String sourceState = extractSourceState(node);
|
||||
if (sourceState == null && node instanceof MethodInvocation mi) {
|
||||
sourceState = extractSourceStateFromArguments(mi);
|
||||
Expression selected = EventArgumentSelector.selectEventArgument(mi).orElse(null);
|
||||
if (EventArgumentSelector.selectedIsFirstArgument(mi, selected)) {
|
||||
sourceState = extractSourceStateFromArguments(mi);
|
||||
}
|
||||
}
|
||||
String stateMachineId = extractStateMachineId(node);
|
||||
String[] smTypes = resolveStateMachineTypeArguments(node);
|
||||
|
||||
boolean external = false;
|
||||
if (!node.arguments().isEmpty()) {
|
||||
Expression eventExpr = (Expression) node.arguments().get(0);
|
||||
AssignmentDag dag = new AssignmentDag(method);
|
||||
external = dag.isExternal(eventExpr, new java.util.HashSet<>());
|
||||
Expression eventExpr = EventArgumentSelector.selectEventArgument(node).orElse(null);
|
||||
if (eventExpr != null) {
|
||||
AssignmentDag dag = new AssignmentDag(method);
|
||||
external = dag.isExternal(eventExpr, new java.util.HashSet<>());
|
||||
}
|
||||
}
|
||||
|
||||
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
|
||||
|
||||
@@ -155,6 +155,106 @@ class InstanceofNarrowerTest {
|
||||
assertThat(refined.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void elseBranchStaysEmpty(@TempDir Path tempDir) throws Exception {
|
||||
Fixture f = scan(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
void fire(Object o) {
|
||||
if (o instanceof PayRichEvent) {
|
||||
} else {
|
||||
use(((PayRichEvent) o).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.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void interfaceInstanceofTargetStaysEmpty(@TempDir Path tempDir) throws Exception {
|
||||
Fixture f = scan(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
void fire(Object o) {
|
||||
if (o instanceof RichEvent) {
|
||||
use(((RichEvent) o).getA());
|
||||
}
|
||||
}
|
||||
void use(String s) {}
|
||||
}
|
||||
interface RichEvent { String getA(); }
|
||||
class PayRichEvent implements RichEvent { public String getA() { return "PAY"; } }
|
||||
""");
|
||||
MethodInvocation getA = findInvocation(f.fire, "getA");
|
||||
ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context);
|
||||
assertThat(facts.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void abstractInstanceofTargetStaysEmpty(@TempDir Path tempDir) throws Exception {
|
||||
Fixture f = scan(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
void fire(Object o) {
|
||||
if (o instanceof BaseEvent) {
|
||||
use(((BaseEvent) o).getA());
|
||||
}
|
||||
}
|
||||
void use(String s) {}
|
||||
}
|
||||
abstract class BaseEvent { abstract String getA(); }
|
||||
class PayRichEvent extends BaseEvent { String getA() { return "PAY"; } }
|
||||
""");
|
||||
MethodInvocation getA = findInvocation(f.fire, "getA");
|
||||
ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context);
|
||||
assertThat(facts.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonSimpleNameReceiverStaysEmpty(@TempDir Path tempDir) throws Exception {
|
||||
Fixture f = scan(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
Holder holder;
|
||||
void fire() {
|
||||
if (holder.getEvent() instanceof PayRichEvent) {
|
||||
use(((PayRichEvent) holder.getEvent()).getA());
|
||||
}
|
||||
}
|
||||
void use(String s) {}
|
||||
}
|
||||
class Holder { Object getEvent() { return null; } }
|
||||
class PayRichEvent { String getA() { return "PAY"; } }
|
||||
""");
|
||||
MethodInvocation getA = findInvocation(f.fire, "getA");
|
||||
ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context);
|
||||
assertThat(facts.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void orGuardInstanceofStaysFailClosed(@TempDir Path tempDir) throws Exception {
|
||||
Fixture f = scan(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
void fire(Object o, boolean flag) {
|
||||
if (flag || o instanceof PayRichEvent) {
|
||||
use(((PayRichEvent) o).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.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
private static Fixture scan(Path tempDir, String source) throws Exception {
|
||||
Files.writeString(tempDir.resolve("C.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.flow.eventid;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class EventIdentityNormalizeTest {
|
||||
|
||||
@Test
|
||||
void sameConstantTreatsBareAndQualifiedAsEqual() {
|
||||
assertThat(EventIdentityNormalize.sameConstant("PAY", "OrderEvent.PAY")).isTrue();
|
||||
assertThat(EventIdentityNormalize.sameConstant("OrderEvent.PAY", "com.example.OrderEvent.PAY")).isTrue();
|
||||
assertThat(EventIdentityNormalize.sameConstant("PAY", "SHIP")).isFalse();
|
||||
assertThat(EventIdentityNormalize.sameConstant("PAY", "<SYMBOLIC: OrderEvent.*>")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void preferQualifiedKeepsDottedForm() {
|
||||
assertThat(EventIdentityNormalize.preferQualified("PAY", "OrderEvent.PAY", null))
|
||||
.isEqualTo("OrderEvent.PAY");
|
||||
assertThat(EventIdentityNormalize.preferQualified("OrderEvent.PAY", "PAY", null))
|
||||
.isEqualTo("OrderEvent.PAY");
|
||||
assertThat(EventIdentityNormalize.preferQualified("PAY", "PAY", "OrderEvent"))
|
||||
.isEqualTo("OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void expandConsultAdoptPolicy() {
|
||||
assertThat(ExpandConsult.empty().adoptableAsPolyExpand()).isFalse();
|
||||
assertThat(ExpandConsult.unique(java.util.List.of("OrderEvent.PAY"), "OrderEvent").adoptableAsPolyExpand())
|
||||
.isFalse();
|
||||
assertThat(ExpandConsult.unique(java.util.List.of("OrderEvent.PAY"), "OrderEvent").isUniqueProof())
|
||||
.isTrue();
|
||||
ExpandConsult expand = ExpandConsult.enumExpand(
|
||||
java.util.List.of("OrderEvent.PAY", "OrderEvent.SHIP"), "OrderEvent");
|
||||
assertThat(expand.adoptableAsPolyExpand()).isTrue();
|
||||
assertThat(EventIdentityPolyPublish.adoptExpandOrEmpty(expand))
|
||||
.containsExactly("OrderEvent.PAY", "OrderEvent.SHIP");
|
||||
assertThat(EventIdentityPolyPublish.adoptExpandOrEmpty(
|
||||
ExpandConsult.unique(java.util.List.of("PAY"), null))).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.flow.eventid;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.flow.alloc.AllocationFactPropagator;
|
||||
import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
|
||||
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.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;
|
||||
|
||||
/**
|
||||
* Unique vs hint-gated expand split for EventIdentityResolver.
|
||||
*/
|
||||
class EventIdentityResolveExpandedTest {
|
||||
|
||||
@Test
|
||||
void toUpperCaseExpandsWhileUniqueStaysEmpty(@TempDir Path tempDir) throws Exception {
|
||||
Fixture f = fixture(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
void process(String action) {
|
||||
send(OrderEvent.valueOf(action.toUpperCase()));
|
||||
}
|
||||
void send(OrderEvent e) {}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
""");
|
||||
List<String> unique = f.resolver.resolveUnique(f.valueOfCall, List.of("com.example.C.process"), Map.of());
|
||||
ExpandConsult consult = f.resolver.resolveExpandConsult(
|
||||
f.valueOfCall, List.of("com.example.C.process"), Map.of());
|
||||
assertThat(unique).isEmpty();
|
||||
assertThat(consult.source()).isEqualTo(ExpandConsult.Source.ENUM_EXPAND);
|
||||
assertThat(consult.licensedByHint()).isTrue();
|
||||
assertThat(consult.members()).containsExactlyInAnyOrder(
|
||||
"OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void localeToUpperCaseStillExpands(@TempDir Path tempDir) throws Exception {
|
||||
Fixture f = fixture(tempDir, """
|
||||
package com.example;
|
||||
import java.util.Locale;
|
||||
class C {
|
||||
void process(String action) {
|
||||
send(OrderEvent.valueOf(action.toUpperCase(Locale.ROOT)));
|
||||
}
|
||||
void send(OrderEvent e) {}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
""");
|
||||
assertThat(f.resolver.resolveUnique(f.valueOfCall, List.of("com.example.C.process"), Map.of())).isEmpty();
|
||||
assertThat(f.resolver.resolveExpanded(f.valueOfCall, List.of("com.example.C.process"), Map.of()))
|
||||
.containsExactlyInAnyOrder("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainValueOfStaysEmptyForUniqueAndExpanded(@TempDir Path tempDir) throws Exception {
|
||||
Fixture f = fixture(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
void process(String action) {
|
||||
send(OrderEvent.valueOf(action));
|
||||
}
|
||||
void send(OrderEvent e) {}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
""");
|
||||
assertThat(f.resolver.resolveUnique(f.valueOfCall, List.of("com.example.C.process"), Map.of())).isEmpty();
|
||||
ExpandConsult consult = f.resolver.resolveExpandConsult(
|
||||
f.valueOfCall, List.of("com.example.C.process"), Map.of());
|
||||
assertThat(consult.source()).isEqualTo(ExpandConsult.Source.EMPTY);
|
||||
assertThat(consult.members()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void allocProvenUniqueAlsoReturnedByExpanded(@TempDir Path tempDir) throws Exception {
|
||||
Fixture f = fixture(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
void pay() {
|
||||
fire(new PayRichEvent());
|
||||
}
|
||||
void fire(RichEvent e) {
|
||||
send(OrderEvent.valueOf(e.getA()));
|
||||
}
|
||||
void send(OrderEvent e) {}
|
||||
}
|
||||
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(); }
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
""");
|
||||
List<String> path = List.of("com.example.C.pay", "com.example.C.fire");
|
||||
Map<String, List<CallEdge>> callGraph = Map.of(
|
||||
"com.example.C.pay",
|
||||
List.of(new CallEdge("com.example.C.fire", List.of("new PayRichEvent()"))));
|
||||
List<String> unique = f.resolver.resolveUnique(f.valueOfCall, path, callGraph);
|
||||
List<String> expanded = f.resolver.resolveExpanded(f.valueOfCall, path, callGraph);
|
||||
assertThat(unique).containsExactly("PAY");
|
||||
assertThat(expanded).containsExactly("OrderEvent.PAY");
|
||||
ExpandConsult consult = f.resolver.resolveExpandConsult(f.valueOfCall, path, callGraph);
|
||||
assertThat(consult.source()).isEqualTo(ExpandConsult.Source.UNIQUE);
|
||||
assertThat(consult.licensedByHint()).isFalse();
|
||||
assertThat(consult.adoptableAsPolyExpand()).isFalse();
|
||||
assertThat(consult.isUniqueProof()).isTrue();
|
||||
assertThat(consult.members()).containsExactly("OrderEvent.PAY");
|
||||
assertThat(EventIdentityNormalize.sameConstant(unique.get(0), consult.members().get(0))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void helperReturnNameExpandsViaConsult(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("C.java"), """
|
||||
package com.example;
|
||||
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();
|
||||
}
|
||||
}
|
||||
enum LogisticsEvent { DELIVERED, RETURNED }
|
||||
""");
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
ConstantResolver cr = context.getConstantResolver();
|
||||
VariableTracer vt = new VariableTracer(context, cr);
|
||||
ConstructorAnalyzer ctor = new ConstructorAnalyzer(context, cr);
|
||||
AllocationFactPropagator afp = new AllocationFactPropagator(context, vt, ctor);
|
||||
ConstantExtractor extractor = new ConstantExtractor(context, cr, mi -> null);
|
||||
AccessorResolver accessors = new AccessorResolver(context, extractor, ctor, cr);
|
||||
VirtualAccessorConstResolver vacr = new VirtualAccessorConstResolver(context, accessors, cr);
|
||||
EventIdentityResolver resolver = new EventIdentityResolver(context, afp, vacr, vt, cr);
|
||||
TypeDeclaration td = context.getTypeDeclaration("com.example.AbstractOrderService");
|
||||
assertThat(td).isNotNull();
|
||||
Expression sendArg = null;
|
||||
for (MethodDeclaration md : td.getMethods()) {
|
||||
if (!"process".equals(md.getName().getIdentifier()) || md.getBody() == null) {
|
||||
continue;
|
||||
}
|
||||
Expression[] found = {null};
|
||||
md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if ("sendEvent".equals(node.getName().getIdentifier()) && !node.arguments().isEmpty()) {
|
||||
found[0] = (Expression) node.arguments().get(0);
|
||||
return false;
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
sendArg = found[0];
|
||||
}
|
||||
assertThat(sendArg).isNotNull();
|
||||
List<String> path = List.of(
|
||||
"com.example.LogisticsService.process",
|
||||
"com.example.AbstractOrderService.sendEvent");
|
||||
ExpandConsult consult = resolver.resolveExpandConsult(sendArg, path, Map.of());
|
||||
assertThat(consult.source()).isEqualTo(ExpandConsult.Source.ENUM_EXPAND);
|
||||
assertThat(consult.licensedByHint()).isTrue();
|
||||
assertThat(consult.members()).containsExactlyInAnyOrder(
|
||||
"LogisticsEvent.DELIVERED", "LogisticsEvent.RETURNED");
|
||||
assertThat(consult.adoptableAsPolyExpand()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void helperExpandStaysEmptyWhenOnlyAbstractOnPath(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("C.java"), """
|
||||
package com.example;
|
||||
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 }
|
||||
""");
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
ConstantResolver cr = context.getConstantResolver();
|
||||
VariableTracer vt = new VariableTracer(context, cr);
|
||||
ConstructorAnalyzer ctor = new ConstructorAnalyzer(context, cr);
|
||||
AllocationFactPropagator afp = new AllocationFactPropagator(context, vt, ctor);
|
||||
ConstantExtractor extractor = new ConstantExtractor(context, cr, mi -> null);
|
||||
AccessorResolver accessors = new AccessorResolver(context, extractor, ctor, cr);
|
||||
VirtualAccessorConstResolver vacr = new VirtualAccessorConstResolver(context, accessors, cr);
|
||||
EventIdentityResolver resolver = new EventIdentityResolver(context, afp, vacr, vt, cr);
|
||||
TypeDeclaration td = context.getTypeDeclaration("com.example.AbstractOrderService");
|
||||
Expression sendArg = null;
|
||||
for (MethodDeclaration md : td.getMethods()) {
|
||||
if (!"process".equals(md.getName().getIdentifier()) || md.getBody() == null) {
|
||||
continue;
|
||||
}
|
||||
Expression[] found = {null};
|
||||
md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if ("sendEvent".equals(node.getName().getIdentifier()) && !node.arguments().isEmpty()) {
|
||||
found[0] = (Expression) node.arguments().get(0);
|
||||
return false;
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
sendArg = found[0];
|
||||
}
|
||||
assertThat(sendArg).isNotNull();
|
||||
// Abstract frames only — must not flatten Logistics+Computer overrides.
|
||||
List<String> abstractOnlyPath = List.of(
|
||||
"com.example.AbstractOrderService.process",
|
||||
"com.example.AbstractOrderService.sendEvent");
|
||||
ExpandConsult miss = resolver.resolveExpandConsult(sendArg, abstractOnlyPath, Map.of());
|
||||
assertThat(miss.source()).isEqualTo(ExpandConsult.Source.EMPTY);
|
||||
assertThat(miss.adoptableAsPolyExpand()).isFalse();
|
||||
}
|
||||
|
||||
private static Fixture fixture(Path tempDir, String source) throws Exception {
|
||||
Files.writeString(tempDir.resolve("C.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
ConstantResolver cr = context.getConstantResolver();
|
||||
VariableTracer vt = new VariableTracer(context, cr);
|
||||
ConstructorAnalyzer ctor = new ConstructorAnalyzer(context, cr);
|
||||
AllocationFactPropagator afp = new AllocationFactPropagator(context, vt, ctor);
|
||||
ConstantExtractor extractor = new ConstantExtractor(context, cr, mi -> null);
|
||||
AccessorResolver accessors = new AccessorResolver(context, extractor, ctor, cr);
|
||||
VirtualAccessorConstResolver vacr = new VirtualAccessorConstResolver(context, accessors, cr);
|
||||
EventIdentityResolver resolver = new EventIdentityResolver(context, afp, vacr, vt, cr);
|
||||
TypeDeclaration td = context.getTypeDeclaration("com.example.C");
|
||||
if (td == null) {
|
||||
td = context.getTypeDeclaration("C");
|
||||
}
|
||||
assertThat(td).isNotNull();
|
||||
MethodInvocation valueOf = null;
|
||||
for (MethodDeclaration md : td.getMethods()) {
|
||||
MethodInvocation[] found = {null};
|
||||
md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if ("valueOf".equals(node.getName().getIdentifier())) {
|
||||
found[0] = node;
|
||||
return false;
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
if (found[0] != null) {
|
||||
valueOf = found[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertThat(valueOf).isNotNull();
|
||||
return new Fixture(resolver, valueOf);
|
||||
}
|
||||
|
||||
private record Fixture(EventIdentityResolver resolver, MethodInvocation valueOfCall) {
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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.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.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class EventArgumentSelectorTest {
|
||||
|
||||
@Test
|
||||
void singleArgIsSelected(@TempDir Path tempDir) throws Exception {
|
||||
MethodInvocation call = sendEventCall(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
StateMachine sm;
|
||||
void fire(OrderEvent e) {
|
||||
sm.sendEvent(e);
|
||||
}
|
||||
}
|
||||
class StateMachine { void sendEvent(OrderEvent e) {} }
|
||||
enum OrderEvent { PAY }
|
||||
""");
|
||||
Optional<Expression> selected = EventArgumentSelector.selectEventArgument(call);
|
||||
assertThat(selected).isPresent();
|
||||
assertThat(selected.get().toString()).isEqualTo("e");
|
||||
assertThat(EventArgumentSelector.selectedIsFirstArgument(call, selected.get())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventThenStateSelectsEvent(@TempDir Path tempDir) throws Exception {
|
||||
MethodInvocation call = sendEventCall(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
StateMachine sm;
|
||||
void fire() {
|
||||
sm.sendEvent(OrderEvent.PAY, OrderState.PENDING);
|
||||
}
|
||||
}
|
||||
class StateMachine { void sendEvent(OrderEvent e, OrderState s) {} }
|
||||
enum OrderEvent { PAY }
|
||||
enum OrderState { PENDING }
|
||||
""");
|
||||
Optional<Expression> selected = EventArgumentSelector.selectEventArgument(call);
|
||||
assertThat(selected).isPresent();
|
||||
assertThat(selected.get().toString()).contains("OrderEvent.PAY");
|
||||
assertThat(EventArgumentSelector.selectedIsFirstArgument(call, selected.get())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void headersThenEventSelectsEvent(@TempDir Path tempDir) throws Exception {
|
||||
MethodInvocation call = sendEventCall(tempDir, """
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
class C {
|
||||
StateMachine sm;
|
||||
void fire(Map<String, Object> headers, String code) {
|
||||
sm.sendEvent(headers, OrderEvent.valueOf(code));
|
||||
}
|
||||
}
|
||||
class StateMachine { void sendEvent(Map<String, Object> headers, OrderEvent e) {} }
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
""");
|
||||
Optional<Expression> selected = EventArgumentSelector.selectEventArgument(call);
|
||||
assertThat(selected).isPresent();
|
||||
assertThat(selected.get().toString()).contains("valueOf");
|
||||
assertThat(EventArgumentSelector.selectedIsFirstArgument(call, selected.get())).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ambiguousTwoArgsStayEmpty(@TempDir Path tempDir) throws Exception {
|
||||
MethodInvocation call = sendEventCall(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
StateMachine sm;
|
||||
void fire(String a, String b) {
|
||||
sm.sendEvent(a, b);
|
||||
}
|
||||
}
|
||||
class StateMachine { void sendEvent(String a, String b) {} }
|
||||
""");
|
||||
assertThat(EventArgumentSelector.selectEventArgument(call)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroArgsStayEmpty(@TempDir Path tempDir) throws Exception {
|
||||
MethodInvocation call = sendEventCall(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
StateMachine sm;
|
||||
void fire() {
|
||||
sm.sendEvent();
|
||||
}
|
||||
}
|
||||
class StateMachine { void sendEvent() {} }
|
||||
""");
|
||||
assertThat(EventArgumentSelector.selectEventArgument(call)).isEmpty();
|
||||
}
|
||||
|
||||
private static MethodInvocation sendEventCall(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, "fire", true);
|
||||
assertThat(md).isNotNull();
|
||||
MethodInvocation[] found = {null};
|
||||
md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if ("sendEvent".equals(node.getName().getIdentifier())) {
|
||||
found[0] = node;
|
||||
return false;
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
assertThat(found[0]).isNotNull();
|
||||
return found[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Peel vs expand-hint split: 0-arg peel for EI; any-arity case transforms + name() for ACE.
|
||||
*/
|
||||
class IdentityStringTransformSupportTest {
|
||||
|
||||
@Test
|
||||
void zeroArgToUpperCaseIsPeelAndExpand(@TempDir Path tempDir) throws Exception {
|
||||
MethodInvocation mi = invocation(tempDir, "map", """
|
||||
package com.example;
|
||||
class C {
|
||||
String map(String action) {
|
||||
return action.toUpperCase();
|
||||
}
|
||||
}
|
||||
""");
|
||||
assertThat(IdentityStringTransformSupport.isPeelInvocation(mi)).isTrue();
|
||||
assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void localeToUpperCaseIsExpandNotPeel(@TempDir Path tempDir) throws Exception {
|
||||
MethodInvocation mi = invocation(tempDir, "map", """
|
||||
package com.example;
|
||||
import java.util.Locale;
|
||||
class C {
|
||||
String map(String action) {
|
||||
return action.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
""");
|
||||
assertThat(IdentityStringTransformSupport.isPeelInvocation(mi)).isFalse();
|
||||
assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroArgToLowerCaseIsPeelAndExpand(@TempDir Path tempDir) throws Exception {
|
||||
MethodInvocation mi = invocation(tempDir, "map", """
|
||||
package com.example;
|
||||
class C {
|
||||
String map(String action) {
|
||||
return action.toLowerCase();
|
||||
}
|
||||
}
|
||||
""");
|
||||
assertThat(IdentityStringTransformSupport.isPeelInvocation(mi)).isTrue();
|
||||
assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void localeToLowerCaseIsExpandNotPeel(@TempDir Path tempDir) throws Exception {
|
||||
MethodInvocation mi = invocation(tempDir, "map", """
|
||||
package com.example;
|
||||
import java.util.Locale;
|
||||
class C {
|
||||
String map(String action) {
|
||||
return action.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
""");
|
||||
assertThat(IdentityStringTransformSupport.isPeelInvocation(mi)).isFalse();
|
||||
assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void trimIsPeelNotExpand(@TempDir Path tempDir) throws Exception {
|
||||
MethodInvocation mi = invocation(tempDir, "map", """
|
||||
package com.example;
|
||||
class C {
|
||||
String map(String action) {
|
||||
return action.trim();
|
||||
}
|
||||
}
|
||||
""");
|
||||
assertThat(IdentityStringTransformSupport.isPeelInvocation(mi)).isTrue();
|
||||
assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isFalse();
|
||||
assertThat(IdentityStringTransformSupport.expressionHasExpandHint(mi)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroArgNameIsExpandNotPeel(@TempDir Path tempDir) throws Exception {
|
||||
MethodInvocation mi = invocation(tempDir, "map", """
|
||||
package com.example;
|
||||
class C {
|
||||
String map(OrderEvent e) {
|
||||
return e.name();
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY }
|
||||
""");
|
||||
assertThat(IdentityStringTransformSupport.isPeelInvocation(mi)).isFalse();
|
||||
assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNameIsNeither(@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(IdentityStringTransformSupport.expressionHasExpandHint(expr)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void peelIdentityStringOpsUnwrapsTrimThenUpper(@TempDir Path tempDir) throws Exception {
|
||||
Expression expr = returnExpr(tempDir, """
|
||||
package com.example;
|
||||
class C {
|
||||
String map(String action) {
|
||||
return action.trim().toUpperCase();
|
||||
}
|
||||
}
|
||||
""");
|
||||
Expression peeled = IdentityStringTransformSupport.peelIdentityStringOps(expr);
|
||||
assertThat(peeled.toString()).isEqualTo("action");
|
||||
}
|
||||
|
||||
@Test
|
||||
void peelIdentityStringOpsLeavesLocaleIntact(@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);
|
||||
}
|
||||
}
|
||||
""");
|
||||
Expression peeled = IdentityStringTransformSupport.peelIdentityStringOps(expr);
|
||||
assertThat(peeled.toString()).contains("toUpperCase");
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainValueOfHasNoExpandHint(@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(IdentityStringTransformSupport.expressionHasExpandHint(expr)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnExpressionsHaveExpandHintIgnoresSideEffectNoise(@TempDir Path tempDir) throws Exception {
|
||||
MethodDeclaration noisy = method(tempDir, "map", """
|
||||
package com.example;
|
||||
class C {
|
||||
String map(String s) {
|
||||
String ignored = OrderEvent.PAY.name();
|
||||
return MyEvents.valueOf(s);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY }
|
||||
enum MyEvents { A, B }
|
||||
""");
|
||||
assertThat(IdentityStringTransformSupport.methodBodyHasExpandHint(noisy)).isTrue();
|
||||
assertThat(IdentityStringTransformSupport.returnExpressionsHaveExpandHint(noisy)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnExpressionsHaveExpandHintForValueOfName(@TempDir Path tempDir) throws Exception {
|
||||
MethodDeclaration md = method(tempDir, "map", """
|
||||
package com.example;
|
||||
class C {
|
||||
String map(String event) {
|
||||
return OrderEvent.valueOf(event).name();
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY }
|
||||
""");
|
||||
assertThat(IdentityStringTransformSupport.returnExpressionsHaveExpandHint(md)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nameWithArgsIsNotExpandHint(@TempDir Path tempDir) throws Exception {
|
||||
MethodInvocation mi = invocation(tempDir, "map", """
|
||||
package com.example;
|
||||
class C {
|
||||
String map(Weird w) {
|
||||
return w.name("x");
|
||||
}
|
||||
}
|
||||
class Weird { String name(String s) { return s; } }
|
||||
""");
|
||||
assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stringToUpperCaseMethodReferenceIsExpandNotPeel(@TempDir Path tempDir) throws Exception {
|
||||
Expression expr = returnExpr(tempDir, """
|
||||
package com.example;
|
||||
import java.util.function.Function;
|
||||
class C {
|
||||
Function<String, String> map() {
|
||||
return String::toUpperCase;
|
||||
}
|
||||
}
|
||||
""");
|
||||
assertThat(IdentityStringTransformSupport.isExpandHintMethodReference(expr)).isTrue();
|
||||
assertThat(IdentityStringTransformSupport.expressionHasExpandHint(expr)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void enumNameMethodReferenceIsExpandHint(@TempDir Path tempDir) throws Exception {
|
||||
Expression expr = returnExpr(tempDir, """
|
||||
package com.example;
|
||||
import java.util.function.Function;
|
||||
class C {
|
||||
Function<OrderEvent, String> map() {
|
||||
return OrderEvent::name;
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY }
|
||||
""");
|
||||
assertThat(IdentityStringTransformSupport.isExpandHintMethodReference(expr)).isTrue();
|
||||
assertThat(IdentityStringTransformSupport.expressionHasExpandHint(expr)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNameMethodReferenceIsNotExpandHint(@TempDir Path tempDir) throws Exception {
|
||||
Expression expr = returnExpr(tempDir, """
|
||||
package com.example;
|
||||
import java.util.function.Function;
|
||||
class C {
|
||||
Function<Person, String> map() {
|
||||
return Person::getName;
|
||||
}
|
||||
}
|
||||
class Person { String getName() { return "x"; } }
|
||||
""");
|
||||
assertThat(IdentityStringTransformSupport.isExpandHintMethodReference(expr)).isFalse();
|
||||
assertThat(IdentityStringTransformSupport.expressionHasExpandHint(expr)).isFalse();
|
||||
}
|
||||
|
||||
private static MethodInvocation invocation(Path tempDir, String methodName, String source)
|
||||
throws Exception {
|
||||
MethodDeclaration md = method(tempDir, methodName, source);
|
||||
MethodInvocation[] found = {null};
|
||||
md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
found[0] = node;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
assertThat(found[0]).isNotNull();
|
||||
return found[0];
|
||||
}
|
||||
|
||||
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 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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Adversarial coverage for claimed AFP fail-closed / PostConstruct rules.
|
||||
*/
|
||||
class AllocationCoverageBugHuntTest {
|
||||
|
||||
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
|
||||
private static final String EVENT_TYPE = "com.example.OrderEvent";
|
||||
private static final String STATE_TYPE = "com.example.OrderState";
|
||||
|
||||
private static final String COMMON_TAIL = """
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
enum OrderState { NEW, PAID, SHIPPED, CANCELLED }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
|
||||
@Test
|
||||
void postConstructUniqueFieldWriteResolves(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
import javax.annotation.PostConstruct;
|
||||
public class ApiController {
|
||||
Dispatcher dispatcher;
|
||||
public void pay() { dispatcher.fire(); }
|
||||
}
|
||||
class Dispatcher {
|
||||
StateMachine machine;
|
||||
RichEvent carrier;
|
||||
@PostConstruct
|
||||
void init() {
|
||||
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(); }
|
||||
}
|
||||
""" + COMMON_TAIL;
|
||||
assertResolvedPay(source, tempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameTypeMultiFieldWriteStaysFailClosed(@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;
|
||||
RichEvent carrier;
|
||||
void fire(RichEvent e) {
|
||||
this.carrier = e;
|
||||
this.carrier = new PayRichEvent();
|
||||
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(); }
|
||||
}
|
||||
""" + COMMON_TAIL;
|
||||
assertFailClosed(source, "pay", tempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sequentialLocalReassignmentLastWriteWins(@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() {
|
||||
RichEvent e = new PayRichEvent();
|
||||
e = new ShipRichEvent();
|
||||
machine.sendEvent(OrderEvent.valueOf(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(); }
|
||||
}
|
||||
""" + COMMON_TAIL;
|
||||
assertResolvedShip(source, tempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void branchingLocalAssignmentStaysFailClosed(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
Dispatcher dispatcher;
|
||||
public void pay(boolean flag) { dispatcher.fire(flag); }
|
||||
}
|
||||
class Dispatcher {
|
||||
StateMachine machine;
|
||||
void fire(boolean flag) {
|
||||
RichEvent e;
|
||||
if (flag) {
|
||||
e = new PayRichEvent();
|
||||
} else {
|
||||
e = new ShipRichEvent();
|
||||
}
|
||||
machine.sendEvent(OrderEvent.valueOf(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(); }
|
||||
}
|
||||
""" + COMMON_TAIL;
|
||||
assertFailClosed(source, "pay", tempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cicTernaryDifferentConcretesStaysFailClosed(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
Dispatcher dispatcher;
|
||||
public void pay(boolean flag) { dispatcher.fire(flag); }
|
||||
}
|
||||
class Dispatcher {
|
||||
StateMachine machine;
|
||||
void fire(boolean flag) {
|
||||
RichEvent e = flag ? new PayRichEvent() : new ShipRichEvent();
|
||||
machine.sendEvent(OrderEvent.valueOf(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(); }
|
||||
}
|
||||
""" + COMMON_TAIL;
|
||||
assertFailClosed(source, "pay", tempDir);
|
||||
}
|
||||
|
||||
private static void assertResolvedPay(String source, Path tempDir) throws Exception {
|
||||
assertResolvedEvent(source, tempDir, "pay", EVENT_TYPE + ".PAY", "PAY");
|
||||
}
|
||||
|
||||
private static void assertResolvedShip(String source, Path tempDir) throws Exception {
|
||||
assertResolvedEvent(source, tempDir, "pay", EVENT_TYPE + ".SHIP", "SHIP");
|
||||
}
|
||||
|
||||
private static void assertResolvedEvent(
|
||||
String source,
|
||||
Path tempDir,
|
||||
String entryMethod,
|
||||
String expectedEventFqn,
|
||||
String expectedToken) throws Exception {
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||
Transition cancel = transition("NEW", "CANCELLED", EVENT_TYPE + ".CANCEL");
|
||||
CallChain linked = resolveLinkAndAssertSingleMatch(
|
||||
context,
|
||||
"com.example.ApiController",
|
||||
entryMethod,
|
||||
"com.example.StateMachine",
|
||||
"sendEvent",
|
||||
MACHINE_CONFIG,
|
||||
EVENT_TYPE,
|
||||
STATE_TYPE,
|
||||
expectedEventFqn,
|
||||
EngineKind.JDT,
|
||||
pay,
|
||||
ship,
|
||||
cancel);
|
||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
|
||||
.filteredOn(pe -> pe != null && (pe.contains("PAY") || pe.contains("SHIP") || pe.contains("CANCEL")))
|
||||
.allMatch(pe -> pe.contains(expectedToken));
|
||||
}
|
||||
|
||||
private static void assertFailClosed(String source, String entryMethod, Path tempDir) throws Exception {
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||
CallChain raw = resolveChain(context, "com.example.ApiController", entryMethod,
|
||||
"com.example.StateMachine", "sendEvent", EngineKind.JDT);
|
||||
CallChain linked = linkChain(context, raw, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship);
|
||||
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
|
||||
assertThat(linked.getLinkResolution())
|
||||
.isIn(LinkResolution.AMBIGUOUS_WIDEN, LinkResolution.NO_MATCH, LinkResolution.UNRESOLVED_EXTERNAL);
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,129 @@ class BeanAsAllocEventIdentityTest {
|
||||
.isIn(LinkResolution.AMBIGUOUS_WIDEN, LinkResolution.NO_MATCH, LinkResolution.UNRESOLVED_EXTERNAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void qualifierDisambiguatesMultiComponent(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("App.java"), """
|
||||
package com.example;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
public class ApiController {
|
||||
Dispatcher dispatcher;
|
||||
public void pay() { dispatcher.fire(); }
|
||||
}
|
||||
class Dispatcher {
|
||||
StateMachine machine;
|
||||
@Qualifier("pay")
|
||||
RichEvent carrier;
|
||||
void fire() {
|
||||
machine.sendEvent(OrderEvent.valueOf(carrier.getA()));
|
||||
}
|
||||
}
|
||||
interface RichEvent { String getA(); }
|
||||
@Component
|
||||
@Qualifier("pay")
|
||||
class PayRichEvent implements RichEvent {
|
||||
public String getA() { return OrderEvent.PAY.name(); }
|
||||
}
|
||||
@Component
|
||||
@Qualifier("ship")
|
||||
class ShipRichEvent implements RichEvent {
|
||||
public String getA() { return OrderEvent.SHIP.name(); }
|
||||
}
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
enum OrderState { NEW, PAID, SHIPPED, CANCELLED }
|
||||
class OrderStateMachineConfig {}
|
||||
""");
|
||||
|
||||
CallChain linked = linkWithInjection(tempDir);
|
||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||
assertThat(linked.getMatchedTransitions()).hasSize(1);
|
||||
assertThat(linked.getMatchedTransitions().get(0).getEvent())
|
||||
.isEqualTo("com.example.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void uniqueBeanFactoryMethodAsAllocResolves(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("App.java"), """
|
||||
package com.example;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
public class ApiController {
|
||||
Dispatcher dispatcher;
|
||||
public void pay() { dispatcher.fire(); }
|
||||
}
|
||||
class Dispatcher {
|
||||
StateMachine machine;
|
||||
RichEvent carrier;
|
||||
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(); }
|
||||
}
|
||||
@Configuration
|
||||
class EventConfig {
|
||||
@Bean
|
||||
RichEvent payCarrier() {
|
||||
return new PayRichEvent();
|
||||
}
|
||||
}
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
enum OrderState { NEW, PAID, SHIPPED, CANCELLED }
|
||||
class OrderStateMachineConfig {}
|
||||
""");
|
||||
|
||||
CallChain linked = linkWithInjection(tempDir);
|
||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||
assertThat(linked.getMatchedTransitions()).hasSize(1);
|
||||
assertThat(linked.getMatchedTransitions().get(0).getEvent())
|
||||
.isEqualTo("com.example.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
private static CallChain linkWithInjection(Path tempDir) throws Exception {
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.setClasspath(ToolingClasspath.currentJvmJarEntries());
|
||||
context.scan(Set.of(tempDir), Set.of());
|
||||
|
||||
SpringBeanRegistry registry = new SpringBeanRegistry();
|
||||
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||
cu.accept(new SpringContextScanner(registry));
|
||||
}
|
||||
InjectionPointAnalyzer injectionAnalyzer =
|
||||
new InjectionPointAnalyzer(new SpringDependencyResolver(registry));
|
||||
JdtCallGraphEngine engine = new JdtCallGraphEngine(context, injectionAnalyzer);
|
||||
|
||||
EntryPoint entry = EntryPoint.builder()
|
||||
.className("com.example.ApiController")
|
||||
.methodName("pay")
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("e")
|
||||
.build();
|
||||
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
|
||||
assertThat(chains).hasSize(1);
|
||||
|
||||
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.OrderStateMachineConfig")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(chains).build())
|
||||
.build();
|
||||
new TransitionLinkerEnricher().enrich(result, null, null);
|
||||
return result.getMetadata().getCallChains().get(0);
|
||||
}
|
||||
|
||||
private static Transition transition(String source, String target, String eventFqn) {
|
||||
Transition transition = new Transition();
|
||||
transition.setSourceStates(List.of(State.of(source, source)));
|
||||
|
||||
@@ -17,8 +17,9 @@ 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.
|
||||
* Adversarial regression for {@code IdentityStringTransformSupport} expand-hint wiring in ACE.
|
||||
* Locale arity expands here; EventIdentity peels stay 0-arg only
|
||||
* ({@code EventIdentityResolverPipelineTest#toUpperCaseWithLocaleStaysFailClosed}).
|
||||
*/
|
||||
class EnumExpansionHintBugHuntTest {
|
||||
|
||||
@@ -57,6 +58,42 @@ class EnumExpansionHintBugHuntTest {
|
||||
.contains("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void valueOfActionToLowerCaseExpandsToEnumMembers(@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.toLowerCase()));
|
||||
}
|
||||
}
|
||||
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 valueOfViaStringToUpperCaseMethodReferenceExpands(@TempDir Path tempDir) throws IOException {
|
||||
CallChain chain = resolveSendEvent(tempDir, """
|
||||
package com.example;
|
||||
import java.util.Optional;
|
||||
public class ApiController {
|
||||
StateMachine sm;
|
||||
public void processAction(String action) {
|
||||
sm.sendEvent(OrderEvent.valueOf(
|
||||
Optional.of(action).map(String::toUpperCase).orElse(action)));
|
||||
}
|
||||
}
|
||||
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 = """
|
||||
@@ -174,6 +211,103 @@ class EnumExpansionHintBugHuntTest {
|
||||
.noneMatch(e -> e != null && e.startsWith("OrderEvent.") && !e.contains("SYMBOLIC"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noiseNameInsideMappedHelperDoesNotExpand(@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) {
|
||||
String ignored = OrderEvent.PAY.name();
|
||||
return MyEvents.valueOf(str);
|
||||
}
|
||||
public void sendEvent(MyEvents e) {}
|
||||
}
|
||||
enum MyEvents { A, B }
|
||||
enum OrderEvent { PAY }
|
||||
""";
|
||||
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);
|
||||
List<String> poly = chains.get(0).getTriggerPoint().getPolymorphicEvents();
|
||||
assertThat(poly)
|
||||
.as("noise .name() in helper body must not expand, got %s", poly)
|
||||
.noneMatch(e -> e != null && (e.equals("MyEvents.A") || e.equals("MyEvents.B")));
|
||||
assertThat(poly.stream().anyMatch(e -> e != null && e.contains("SYMBOLIC")))
|
||||
.as("expected symbolic poly, got %s", poly)
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void threeHopPlainValueOfStaysSymbolic(@TempDir Path tempDir) throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class Endpoint {
|
||||
private Facade facade;
|
||||
public void trigger(String eventString) {
|
||||
facade.accept(eventString);
|
||||
}
|
||||
}
|
||||
class Facade {
|
||||
private OrderService service;
|
||||
public void accept(String str) {
|
||||
service.doSomething(str);
|
||||
}
|
||||
}
|
||||
class OrderService {
|
||||
public void doSomething(String str) {
|
||||
MyEvents event = MyEvents.valueOf(str);
|
||||
sendEvent(event);
|
||||
}
|
||||
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);
|
||||
List<String> poly = chains.get(0).getTriggerPoint().getPolymorphicEvents();
|
||||
assertThat(poly)
|
||||
.as("3-hop plain valueOf must stay symbolic, got %s", poly)
|
||||
.noneMatch(e -> e != null && (e.equals("MyEvents.A") || e.equals("MyEvents.B")));
|
||||
assertThat(poly.stream().anyMatch(e -> e != null && e.contains("SYMBOLIC")))
|
||||
.as("expected symbolic poly, got %s", poly)
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiTypeEnterpriseDispatcherStaysMultiSymbolic() throws IOException {
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
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.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Adversarial coverage for {@code EventArgumentSelector} wiring in detector / ACE / EI.
|
||||
*/
|
||||
class EventArgumentSelectorBugHuntTest {
|
||||
|
||||
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
|
||||
private static final String EVENT_TYPE = "com.example.OrderEvent";
|
||||
private static final String STATE_TYPE = "com.example.OrderState";
|
||||
|
||||
@Test
|
||||
void sendEventHeadersThenEventResolvesPay(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
public class ApiController {
|
||||
Dispatcher dispatcher;
|
||||
public void pay() { dispatcher.fire(Map.of(), "PAY"); }
|
||||
}
|
||||
class Dispatcher {
|
||||
StateMachine machine;
|
||||
void fire(Map<String, Object> headers, String code) {
|
||||
machine.sendEvent(headers, OrderEvent.valueOf(code));
|
||||
}
|
||||
}
|
||||
class StateMachine { public void sendEvent(Map<String, Object> headers, OrderEvent e) {} }
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
enum OrderState { NEW, PAID, SHIPPED, CANCELLED }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||
CallChain linked = resolveLinkAndAssertSingleMatch(
|
||||
context,
|
||||
"com.example.ApiController",
|
||||
"pay",
|
||||
"com.example.StateMachine",
|
||||
"sendEvent",
|
||||
MACHINE_CONFIG,
|
||||
EVENT_TYPE,
|
||||
STATE_TYPE,
|
||||
EVENT_TYPE + ".PAY",
|
||||
EngineKind.JDT,
|
||||
pay,
|
||||
ship);
|
||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||
assertThat(linked.getTriggerPoint().getEvent())
|
||||
.as("event stamp must not be the headers arg")
|
||||
.doesNotContain("headers")
|
||||
.doesNotContain("Map");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendEventEventThenStateStillDetectsSourceState(@TempDir Path tempDir) throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderService {
|
||||
private StateMachine sm;
|
||||
public void pay() {
|
||||
sm.sendEvent(OrderEvent.PAY, OrderState.PENDING);
|
||||
}
|
||||
}
|
||||
class StateMachine {
|
||||
public void sendEvent(OrderEvent e, OrderState s) {}
|
||||
}
|
||||
enum OrderState { PENDING }
|
||||
enum OrderEvent { PAY }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("OrderService.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
GenericEventDetector detector = new GenericEventDetector(
|
||||
context, new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver(),
|
||||
Collections.emptyList());
|
||||
List<TriggerPoint> triggers = detector.detect(
|
||||
(org.eclipse.jdt.core.dom.CompilationUnit)
|
||||
context.getTypeDeclaration("com.example.OrderService").getRoot());
|
||||
assertThat(triggers).hasSize(1);
|
||||
assertThat(triggers.get(0).getSourceState()).isEqualTo("PENDING");
|
||||
assertThat(triggers.get(0).getEvent()).contains("PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendEventHeadersThenValueOfPolyUsesEventArg(@TempDir Path tempDir) throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
public class ApiController {
|
||||
StateMachine sm;
|
||||
public void process(Map<String, Object> headers, String action) {
|
||||
sm.sendEvent(headers, OrderEvent.valueOf(action.toUpperCase()));
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
class StateMachine {
|
||||
public void sendEvent(Map<String, Object> headers, OrderEvent e) {}
|
||||
}
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("ApiController.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entry = EntryPoint.builder()
|
||||
.className("com.example.ApiController")
|
||||
.methodName("process")
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("e")
|
||||
.build();
|
||||
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
|
||||
assertThat(chains).isNotEmpty();
|
||||
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
|
||||
.contains("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user