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.ReceiverFacts;
|
||||||
import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver;
|
import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
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.LibraryUnwrapRegistry;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.MessageCarrierSupport;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.MessageCarrierSupport;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
|
||||||
@@ -85,10 +87,6 @@ public final class EventIdentityResolver {
|
|||||||
"sendEventMono",
|
"sendEventMono",
|
||||||
"trigger");
|
"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. */
|
/** Compact 0-arg identity accessor names; still gated by unique compile-time body constant. */
|
||||||
private static final Set<String> COMPACT_ACCESSOR_NAMES = Set.of(
|
private static final Set<String> COMPACT_ACCESSOR_NAMES = Set.of(
|
||||||
"type", "event", "code", "a", "id");
|
"type", "event", "code", "a", "id");
|
||||||
@@ -129,6 +127,57 @@ public final class EventIdentityResolver {
|
|||||||
return resolveUnique(expression, methodPath, callGraph, 0, new LinkedHashSet<>());
|
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.
|
* 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);
|
preferred.add(arg);
|
||||||
return super.visit(node);
|
return super.visit(node);
|
||||||
}
|
}
|
||||||
if (isSendLikeName(name) && node.arguments().size() == 1
|
if (isSendLikeName(name) && !node.arguments().isEmpty()) {
|
||||||
&& node.arguments().get(0) instanceof Expression arg) {
|
EventArgumentSelector.selectEventArgument(node).ifPresent(preferred::add);
|
||||||
preferred.add(arg);
|
|
||||||
return super.visit(node);
|
return super.visit(node);
|
||||||
}
|
}
|
||||||
if (node.arguments().size() == 1 && looksLikeValueOfHelper(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.
|
* Resolve preferred exprs; succeed only when every unique proof agrees on the same constant.
|
||||||
* Multiple different unique proofs → empty (fail-closed, no first-wins).
|
* 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(
|
private List<String> resolveUniqueAgreeing(
|
||||||
List<Expression> exprs,
|
List<Expression> exprs,
|
||||||
List<String> methodPath,
|
List<String> methodPath,
|
||||||
Map<String, List<CallEdge>> callGraph) {
|
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) {
|
for (Expression expr : exprs) {
|
||||||
List<String> unique = resolveUnique(expr, methodPath, callGraph);
|
List<String> unique = resolveUnique(expr, methodPath, callGraph);
|
||||||
if (unique.size() == 1) {
|
if (unique.size() != 1) {
|
||||||
proven.add(unique.get(0));
|
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) {
|
if (proven.size() > 1) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return proven.size() == 1 ? List.copyOf(proven.values()) : List.of();
|
||||||
return proven.size() == 1 ? List.copyOf(proven) : List.of();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> resolveUnique(
|
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>.
|
* Peel identity-preserving String ops via {@link IdentityStringTransformSupport}
|
||||||
* Also peels Optional.of / get / orElseThrow (0-arg) wrappers when the receiver is clearly
|
* (0-arg trim/strip/toUpperCase/toLowerCase). Also peels Optional.of / get / orElseThrow
|
||||||
* an Optional/Mono/Flux factory chain — not {@code Supplier.get()}.
|
* (0-arg) wrappers when the receiver is clearly an Optional/Mono/Flux factory chain —
|
||||||
* Locale/{@code replace} stay intact (fail-closed).
|
* not {@code Supplier.get()}. Locale/{@code replace} stay intact (fail-closed).
|
||||||
*/
|
*/
|
||||||
private static Expression peelIdentityString(Expression expression) {
|
private static Expression peelIdentityString(Expression expression) {
|
||||||
Expression current = peel(expression);
|
Expression current = peel(expression);
|
||||||
boolean progressed;
|
boolean progressed;
|
||||||
do {
|
do {
|
||||||
progressed = false;
|
progressed = false;
|
||||||
if (current instanceof MethodInvocation mi
|
Expression afterStringOps = IdentityStringTransformSupport.peelIdentityStringOps(current);
|
||||||
&& mi.getExpression() != null
|
if (afterStringOps != current) {
|
||||||
&& mi.arguments().isEmpty()
|
current = peel(afterStringOps);
|
||||||
&& IDENTITY_STRING_PEELS.contains(mi.getName().getIdentifier())) {
|
|
||||||
current = peel(mi.getExpression());
|
|
||||||
progressed = true;
|
progressed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1778,6 +1837,403 @@ public final class EventIdentityResolver {
|
|||||||
return System.identityHashCode(expression) + "@" + pathKey + "#" + depth;
|
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) {
|
public static boolean isSendLikeName(String name) {
|
||||||
return name != null && SEND_LIKE_NAMES.contains(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.AllocationFactPropagator;
|
||||||
import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver;
|
import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver;
|
||||||
import click.kamil.springstatemachineexporter.analysis.flow.eventid.EventIdentityResolver;
|
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.index.AccessorSummary;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.EnumExpansionHintSupport;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.EventArgumentSelector;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
|
||||||
@@ -1293,7 +1296,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
|
|
||||||
// sendEvent arg may already be <SYMBOLIC:Enum.*> / full enum widen while the dispatcher
|
// 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.
|
// 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)) {
|
&& tryRecoverAllocationSensitiveInto(path, callGraph, polymorphicEvents)) {
|
||||||
isAmbiguous = false;
|
isAmbiguous = false;
|
||||||
}
|
}
|
||||||
@@ -3623,27 +3627,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
return constants;
|
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) {
|
private boolean isRuntimeEnumParameter(Expression exprNode) {
|
||||||
if (!(exprNode instanceof MethodInvocation mi)) {
|
if (!(exprNode instanceof MethodInvocation mi)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -3781,34 +3764,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
if (polymorphicEvents == null) {
|
if (polymorphicEvents == null) {
|
||||||
return polymorphicEvents;
|
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 (polymorphicEvents.isEmpty()) {
|
||||||
if (callerArgumentHasEnumExpansionHint(path)) {
|
if (eiExpand) {
|
||||||
List<String> fromCaller = resolvePolymorphicEventsFromCallerArgument(path);
|
return fromEi;
|
||||||
if (fromCaller.stream().anyMatch(this::looksLikeEnumConstant)) {
|
|
||||||
return fromCaller;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return polymorphicEvents;
|
return polymorphicEvents;
|
||||||
}
|
}
|
||||||
Map<String, String> bindings = resolvePathBindings(path, callGraph, Map.of());
|
Map<String, String> bindings = resolvePathBindings(path, callGraph, Map.of());
|
||||||
if (bindings.isEmpty()) {
|
if (bindings.isEmpty()) {
|
||||||
boolean symbolicOnly = polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant);
|
boolean symbolicOnly = polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant);
|
||||||
if (symbolicOnly) {
|
if (eiExpand && symbolicOnly) {
|
||||||
List<String> hinted = resolvePolymorphicEventsFromCallerArgument(path);
|
return fromEi;
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return polymorphicEvents;
|
return polymorphicEvents;
|
||||||
}
|
}
|
||||||
@@ -3817,22 +3788,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
List<String> bindingResolved = resolvePolymorphicEventsFromBindings(bindings, path);
|
List<String> bindingResolved = resolvePolymorphicEventsFromBindings(bindings, path);
|
||||||
boolean bindingHasConcrete = bindingResolved.stream().anyMatch(this::looksLikeEnumConstant);
|
boolean bindingHasConcrete = bindingResolved.stream().anyMatch(this::looksLikeEnumConstant);
|
||||||
if (bindingHasConcrete) {
|
if (bindingHasConcrete) {
|
||||||
narrowed = new ArrayList<>(bindingResolved);
|
narrowed = new ArrayList<>();
|
||||||
|
for (String candidate : bindingResolved) {
|
||||||
|
addConstantPreferQualified(narrowed, candidate);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for (String candidate : bindingResolved) {
|
for (String candidate : bindingResolved) {
|
||||||
if (candidate != null && !narrowed.contains(candidate)) {
|
addConstantPreferQualified(narrowed, candidate);
|
||||||
narrowed.add(candidate);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
boolean originallySymbolicOnly = polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant);
|
boolean originallySymbolicOnly = polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant);
|
||||||
if (originallySymbolicOnly && narrowed.stream().filter(this::looksLikeEnumConstant).count() <= 1) {
|
if (originallySymbolicOnly
|
||||||
List<String> callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path);
|
&& narrowed.stream().filter(this::looksLikeEnumConstant).count() <= 1
|
||||||
for (String candidate : callerArgumentResolved) {
|
&& eiExpand) {
|
||||||
if (candidate != null && !narrowed.contains(candidate)) {
|
for (String candidate : fromEi) {
|
||||||
narrowed.add(candidate);
|
addConstantPreferQualified(narrowed, candidate);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
boolean hasConcrete = narrowed.stream().anyMatch(this::looksLikeEnumConstant);
|
boolean hasConcrete = narrowed.stream().anyMatch(this::looksLikeEnumConstant);
|
||||||
@@ -3846,6 +3817,67 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
return narrowed;
|
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(
|
private List<String> resolvePolymorphicEventsFromBindings(
|
||||||
Map<String, String> bindings,
|
Map<String, String> bindings,
|
||||||
List<String> path) {
|
List<String> path) {
|
||||||
@@ -3895,344 +3927,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
return resolved.stream().distinct().collect(java.util.stream.Collectors.toList());
|
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) {
|
private String resolveKeyedValueFromMapExpression(Expression mapExpr, String key) {
|
||||||
if (mapExpr == null || key == null) {
|
if (mapExpr == null || key == null) {
|
||||||
return 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.LibraryHint;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
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.MessageCarrierSupport;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
@@ -281,7 +282,10 @@ public class GenericEventDetector {
|
|||||||
eventValue = context.resolveString(forcedEvent);
|
eventValue = context.resolveString(forcedEvent);
|
||||||
} else {
|
} else {
|
||||||
if (node.arguments().isEmpty()) return Collections.emptyList();
|
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
|
// Try MessageBuilder extraction first
|
||||||
eventValue = extractEventFromMessageBuilder(eventExpr);
|
eventValue = extractEventFromMessageBuilder(eventExpr);
|
||||||
@@ -305,17 +309,22 @@ public class GenericEventDetector {
|
|||||||
|
|
||||||
String sourceState = extractSourceState(node);
|
String sourceState = extractSourceState(node);
|
||||||
if (sourceState == null && node instanceof MethodInvocation mi) {
|
if (sourceState == null && node instanceof MethodInvocation mi) {
|
||||||
|
Expression selected = EventArgumentSelector.selectEventArgument(mi).orElse(null);
|
||||||
|
if (EventArgumentSelector.selectedIsFirstArgument(mi, selected)) {
|
||||||
sourceState = extractSourceStateFromArguments(mi);
|
sourceState = extractSourceStateFromArguments(mi);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
String stateMachineId = extractStateMachineId(node);
|
String stateMachineId = extractStateMachineId(node);
|
||||||
String[] smTypes = resolveStateMachineTypeArguments(node);
|
String[] smTypes = resolveStateMachineTypeArguments(node);
|
||||||
|
|
||||||
boolean external = false;
|
boolean external = false;
|
||||||
if (!node.arguments().isEmpty()) {
|
if (!node.arguments().isEmpty()) {
|
||||||
Expression eventExpr = (Expression) node.arguments().get(0);
|
Expression eventExpr = EventArgumentSelector.selectEventArgument(node).orElse(null);
|
||||||
|
if (eventExpr != null) {
|
||||||
AssignmentDag dag = new AssignmentDag(method);
|
AssignmentDag dag = new AssignmentDag(method);
|
||||||
external = dag.isExternal(eventExpr, new java.util.HashSet<>());
|
external = dag.isExternal(eventExpr, new java.util.HashSet<>());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
|
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,106 @@ class InstanceofNarrowerTest {
|
|||||||
assertThat(refined.isEmpty()).isTrue();
|
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 {
|
private static Fixture scan(Path tempDir, String source) throws Exception {
|
||||||
Files.writeString(tempDir.resolve("C.java"), source);
|
Files.writeString(tempDir.resolve("C.java"), source);
|
||||||
CodebaseContext context = new CodebaseContext();
|
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);
|
.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) {
|
private static Transition transition(String source, String target, String eventFqn) {
|
||||||
Transition transition = new Transition();
|
Transition transition = new Transition();
|
||||||
transition.setSourceStates(List.of(State.of(source, source)));
|
transition.setSourceStates(List.of(State.of(source, source)));
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import java.util.Set;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adversarial regression for shared {@code EnumExpansionHintSupport} wiring in ACE:
|
* Adversarial regression for {@code IdentityStringTransformSupport} expand-hint wiring in ACE.
|
||||||
* expand when name/toUpperCase hints are on the arg chain; stay symbolic otherwise.
|
* Locale arity expands here; EventIdentity peels stay 0-arg only
|
||||||
|
* ({@code EventIdentityResolverPipelineTest#toUpperCaseWithLocaleStaysFailClosed}).
|
||||||
*/
|
*/
|
||||||
class EnumExpansionHintBugHuntTest {
|
class EnumExpansionHintBugHuntTest {
|
||||||
|
|
||||||
@@ -57,6 +58,42 @@ class EnumExpansionHintBugHuntTest {
|
|||||||
.contains("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL");
|
.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
|
@Test
|
||||||
void plainValueOfUnboundStaysSymbolic(@TempDir Path tempDir) throws IOException {
|
void plainValueOfUnboundStaysSymbolic(@TempDir Path tempDir) throws IOException {
|
||||||
String source = """
|
String source = """
|
||||||
@@ -174,6 +211,103 @@ class EnumExpansionHintBugHuntTest {
|
|||||||
.noneMatch(e -> e != null && e.startsWith("OrderEvent.") && !e.contains("SYMBOLIC"));
|
.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
|
@Test
|
||||||
void multiTypeEnterpriseDispatcherStaysMultiSymbolic() throws IOException {
|
void multiTypeEnterpriseDispatcherStaysMultiSymbolic() throws IOException {
|
||||||
CodebaseContext context = new CodebaseContext();
|
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